Skip to content
Back to Modules
Part IA Michaelmas Term

Foundations of Computer Science

FoCS OCaml Functional-Programming Recursion Lists Pattern-Matching Datatypes Trees Sorting Complexity Higher-Order-Functions Currying Lazy-Evaluation Queues References Big-O-Notation Part IA Michaelmas Term Paper 1
  • Introduction to Programming

    Abstraction layers and their interfaces, the goals of programming, why we use OCaml (interactive, safe, typed, functional), a first session with let-bindings and functions, the float and int types, recursive definition of power, the efficient divide-and-conquer power function using binary exponentiation, and integer/float conversions

    • Abstraction and Interfaces

      Levels of abstraction

      Large computer systems can only be understood by dividing them into levels of abstraction. Each level provides services to the level above it, implemented using services from the level below. The boundary between levels is the interface.

      Layers of abstraction

      The interface must do two things:

      • Supply the advertised services to the higher level
      • Block access to the implementation details of the lower level

      This abstraction barrier allows one level to be changed without affecting levels above. For example, when a manufacturer designs a faster processor, existing programs must continue to run — differences between old and new processors must be invisible to the program.

      Recurring issues at every level:

      • What services to provide at each level
      • How to implement them using lower-level services
      • The interface that defines how the two levels communicate

      Example: dates

      LevelDetail
      AbstractDates over a certain interval
      ConcreteSix characters: YYMMDD (each character = 8 bits)

      Inadequate internal formats have caused real crises:

      • Digital PDP-10 (1975): Used 12-bit dates — good for at most 2^12 = 4096 days, or about 11 years
      • Year 2000 crisis: Most industry formats used two-digit years (YYMMDD). The common “solution” was adding two further characters, altering file sizes. Yet the existing six characters already provide 48 bits — sufficient for the projected lifetime of the universe: 2^48 = 2.8 × 10^14 days ≈ 7.7 × 10^11 years

      The lesson: mathematicians think in unbounded ranges, but computer representations impose hard limits. A good programming language lets you change the representation easily, but compatibility with old file formats remains a problem.

      Example: floating-point numbers

      A floating-point number is represented internally by two integers (mantissa and exponent). The concept of a data type involves:

      • How a value is represented inside the computer
      • The suite of operations given to programmers
      • Valid and invalid (exceptional) results, such as “infinity”

      Because of finite precision, floating-point computations are potentially inaccurate. For example, computing (2^1/10000)^10000 on a calculator may yield 1.99999959 rather than 2. Certain computations can cause errors to spiral out of control.

      Floating-point numbers are an abstract type: the programmer uses operations like *. and +. without knowing the mantissa/exponent representation, and can change the underlying precision without rewriting code.

      Data types and type safety

      Inside the computer, all data are stored as bits. In most languages, the compiler uses types to generate correct machine code — types are not stored during program execution. Early languages like Fortran barred programmers from mixing types (INTEGER, REAL, COMPLEX). Modern languages handle many kinds of data including text and symbols, and the type system prevents senseless combinations at compile time.

    • Goals of Programming

      What is programming for?

      Programming describes a computation so it can be done mechanically. There are two fundamental building blocks:

      • Expressions compute values (e.g., pi *. r *. r)
      • Commands cause effects (e.g., assignment, input/output)

      Programs should be:

      • Correct — giving the right answers
      • Efficient — giving answers quickly
      • Modifiable — easy to change as requirements evolve

      Programming-in-the-small vs in-the-large

      Programming-in-the-small concerns writing code for simple, clearly defined tasks. Expressions describe mathematical formulae (this was the original contribution of FORTRAN, the FORmula TRANslator). Commands describe how control flows from one part of the program to the next.

      Programming-in-the-large concerns joining large modules to solve complex, messy tasks. As we code layer upon layer, we need mechanisms for one part of the program to provide interfaces to other parts.

      Modularity mechanisms

      Three main approaches to modularity have emerged:

      MechanismDescription
      ModulesEncapsulate a body of code, allowing outside access only through a programmer-defined interface
      Abstract Data Types (ADTs)A simpler version of modules; implement a single concept (e.g., dates, floating-point numbers)
      Object-Oriented Programming (OOP)Classes define concepts and can inherit from other classes; operations can be specialised across a family of related classes; objects are instances holding data

      OCaml has a sophisticated module system that can do many of the same things as classes. OCaml also includes an object system, though it is less commonly used than its module system.

      Why OCaml?

      OCaml is chosen for this course because:

      • Interactive: code can be tried immediately in a read-eval-print loop
      • Flexible data types: a rich type system with type inference
      • Safe: hides the underlying hardware — a program can abort but it cannot crash
      • Mathematically tractable: programs can be designed and understood without thinking in detail about how the computer runs them
      • Immutable by default: distinguishes naming something from updating memory
      • Automatic memory management: manages storage for the programmer

      OCaml occupies a sweet spot combining efficiency, expressiveness, and practicality. It descends from ML, the meta language of Robin Milner’s LCF proof assistant (1972). The modern OCaml emerged in 1996 and has since attracted significant commercial and academic use.

      Correctness and efficiency

      A correct program that runs too slowly is not useful. Conversely, a fast program that gives wrong answers is dangerous. Throughout this course, we study both:

      • How to write programs whose correctness follows from their structure (recursion justified by mathematical induction)
      • How to analyse and improve efficiency using Big O notation and techniques like tail recursion and reduction in strength
    • OCaml Session Basics

      Value declarations

      A let binding gives a name to a value:

      let pi = 3.14159265358979
      (* val pi : float = 3.14159265358979 *)

      This is a value declaration. The name pi is an identifier. OCaml echoes the name, type (float), and value. Unlike variables in imperative languages, pi cannot be reassigned — its meaning persists even if a new let pi = ... is issued later.

      Function declarations

      A function encapsulates a computation so the formula does not need to be remembered:

      let area r = pi *. r *. r
      (* val area : float -> float = <fun> *)

      area takes a float r and returns a float. The arrow -> in the type indicates a function. Calling a function does not require brackets:

      area 2.0
      (* - : float = 12.56637061435916 *)

      Operators

      Multiplication of floats uses *. (not *, which is for integers). This is an infix operator — written between its two operands. Integer multiplication uses *, integer addition uses +, float addition uses +..

      Conditional expressions

      OCaml uses if-then-else expressions, due to John McCarthy:

      if true then x else y = x
      if false then x else y = y

      Evaluation: OCaml first evaluates the condition (of type bool). If the result is true, it evaluates the then branch; if false, the else branch. Crucially, only one branch is evaluated. If both were evaluated, recursive functions would run forever.

      The condition is an expression of type bool, whose two values are true and false.

      Relational operators

      Tests between values use standard operators:

      OperatorMeaning
      =Equality
      <>Inequality
      <Less than
      <=Less than or equal
      >Greater than
      >=Greater than or equal

      Equality testing with = is allowed provided both operands have the same type and equality is defined for that type.

      Boolean operators

      Boolean expressions can be combined:

      OperatorMeaning
      notNegation
      &&Conjunction (and)
      ||Disjunction (or)

      New properties can be declared as functions:

      let even n = n mod 2 = 0
      (* val even : int -> bool = <fun> *)

      Here mod computes the remainder of integer division.

    • Types and Type Inference

      int vs float

      OCaml distinguishes strictly between integer and floating-point types:

      TypeConstantsOperators
      int0, 1, 42+, -, *, /, mod
      float0.0, 1.0, 3.14+., -., *., /.

      The decimal point makes all the difference: 0 is an int; 0.0 is a float. OCaml will never automatically convert between them — this prevents subtle rounding errors.

      Type constraints (annotations)

      Although OCaml normally infers types, you can supply hints for debugging or to force a more specific type:

      let square (x : float) = x *. x
      (* val square : float -> float = <fun> *)

      This constrains x to be float. Alternatively, constrain the result type:

      let square x : float = x *. x
      (* val square : float -> float = <fun> *)

      Type constraints can appear almost anywhere in an expression.

      Type inference

      OCaml is unusual in that it infers types automatically. You rarely need to specify types explicitly. The compiler works out the most general type that an expression can have. For example, from x *. x it deduces x must be float because *. requires float operands.

      Conversion functions

      To mix integers and floats, explicit conversion is required:

      int_of_float 3.14159   (* - : int = 3 *)
      float_of_int 3         (* - : float = 3. *)

      These conversion functions truncate (not round) when going from float to int.

      Modules and compound identifiers

      OCaml’s libraries are organised using modules. Functions from modules are accessed with compound identifiers using dot notation:

      Float.of_int 3    (* equivalent to float_of_int *)
      String.length "abc"
      List.rev [1; 2; 3]

      The module name acts as a namespace, preventing name clashes between different libraries. Thousands of library functions are available for text processing, operating system interaction, and numerical computation.

      Why typed languages?

      Some languages have just one numeric type, converting automatically between formats. This is:

      • Slower, because of runtime conversions
      • Potentially inaccurate, due to unexpected rounding

      OCaml’s type system catches mismatches at compile time, guaranteeing that no type errors occur at runtime. Inside the computer all data are bits; the compiler uses types to generate correct machine code, and types are not stored during program execution.

    • Power Functions

      Naive recursive power: npower

      The function npower raises a float x to a non-negative integer power n:

      let rec npower x n =
        if n = 0 then 1.0
        else x *. npower x (n - 1)
      (* val npower : float -> int -> float = <fun> *)

      The type reads: “pass in a float and an integer to return a float”. The rec keyword indicates the function is recursive — it calls itself.

      Mathematical justification (for x != 0):

      x0=1x^0 = 1 xn+1=x×xnx^{n+1} = x \times x^n

      For n >= 0, the computation terminates because the exponent decreases by 1 each time. For negative n, it would run forever:

      x1=x×x2=x×x×x3=x^{-1} = x \times x^{-2} = x \times x \times x^{-3} = \dots

      Complexity: O(n) time (n multiplications) and O(n) space (not tail-recursive).

      Efficient power: binary exponentiation

      For large n, repeated multiplication is too slow. The square-and-multiply algorithm uses:

      x1=xx^1 = x x2n=(x2)nx^{2n} = (x^2)^n x2n+1=x×(x2)nx^{2n+1} = x \times (x^2)^n

      In OCaml:

      let rec power x n =
        if n = 1 then x
        else if even n then
          power (x *. x) (n / 2)
        else
          x *. power (x *. x) (n / 2)
      (* val power : float -> int -> float = <fun> *)

      Example:

      212=46=163=16×2561=16×256=40962^{12} = 4^6 = 16^3 = 16 \times 256^1 = 16 \times 256 = 4096

      Only 4 multiplications instead of 12! Integer division / truncates, so (2n+1) / 2 = n.

      Complexity: O(log n) time — at most 2 lg n multiplications, where lg n is log base 2. Space is also O(log n) because nesting occurs when the exponent is odd.

      Comparison

      FunctionAlgorithmTimeSpace
      npowerRepeated multiplicationO(n)O(n)
      powerBinary exponentiationO(log n)O(log n)

      For a 32-bit integer exponent, power makes at most 30 recursive calls (for 2^31 - 1). Making power tail-recursive by adding an accumulator is possible but the gain is minute for such small recursion depths.

      The even test

      The helper function uses modular arithmetic:

      let even n = n mod 2 = 0
      (* val even : int -> bool = <fun> *)

      n mod 2 returns the remainder when dividing n by 2. If the remainder is 0, n is even.

      Termination and exactness

      The recurrence is bound to terminate because if n > 0, then n is smaller than both 2n and 2n+1. After enough recursive calls, the exponent reaches 1. The reasoning assumes exact arithmetic — floating-point is well-behaved here, but integer overflow occurs if values exceed the finite range of hardware integers.

  • Recursion and Efficiency

    Expression evaluation as reduction to a value, naive vs iterative summing, tail recursion and accumulators, the exponential cost of sillySum, asymptotic complexity and Big-O notation (formal definition, common facts), complexity classes from constant to exponential, sample costs for the power and sum functions, and recurrence relations for analysing recursive functions

    • Expression Evaluation

      Computation as reduction

      Expression evaluation concerns expressions and the values they return. A computation proceeds by stepwise reduction:

      E0E1E2vE_0 \rightarrow E_1 \rightarrow E_2 \rightarrow \dots \rightarrow v

      We write EEE \rightarrow E' to say that EE reduces to EE'. Mathematically, E=EE = E', but the computation goes from EE to EE' and never the other way.

      A value is something that cannot be further reduced — a number like 42, a string like "hello", or a function like <fun>.

      Functional vs imperative programming

      StyleFocusExample
      FunctionalExpressions computing valuesarea 2.0 returns a float
      ImperativeCommands changing statex := x + 1 updates storage

      Functional programming stays at the level of expressions. This may seem far removed from hardware, but for computing solutions to problems it is entirely adequate. Imperative programming requires a notion of states that can be observed and changed — assigning to variables, performing input/output — leading to conventional programs as coded in C.

      Expressions vs values

      An expression like 3 + 4 is not yet a value; it evaluates to 7. The expression nsum 3 evaluates through a sequence of reductions before yielding the value 6. The key distinction:

      • Expressions may contain function calls, operators, and sub-expressions that need further evaluation
      • Values are the end results: numbers, booleans, strings, functions, or constructed data

      Stepwise reduction in OCaml

      OCaml uses call-by-value evaluation: before applying a function, its argument is fully evaluated to a value. This means:

      1. The argument expression is reduced to a value
      2. The function body is then evaluated with the parameter bound to that value

      This contrasts with call-by-name (evaluate the argument each time it is used) and call-by-need (evaluate at most once, caching the result). OCaml’s strict evaluation means recursive functions must have a base case that is reached without infinite descent.

      Why expression evaluation matters

      Modelling interaction and control (input/output, mutable state) requires additional concepts beyond pure expressions. But for the core of algorithm design — computing results from inputs — expression evaluation provides a clean, mathematically tractable foundation. The OCaml programmer thinks in terms of what an expression is (its value), not what it does (its effects).

    • Naive vs Iterative Summing

      Naive recursive summing: nsum

      let rec nsum n =
        if n = 0 then 0
        else n + nsum (n - 1)
      (* val nsum : int -> int = <fun> *)

      The function computes 1+2++n1 + 2 + \dots + n by recursion. A trace for nsum 3:

      nsum 3 => 3 + (nsum 2)
             => 3 + (2 + (nsum 1))
             => 3 + (2 + (1 + (nsum 0)))
             => 3 + (2 + (1 + 0))
             => 6

      The nesting of parentheses is not just notation — it indicates a real problem. The function gathers up numbers, but none of the additions can be performed until nsum 0 is reached. Meanwhile, the computer must store pending values on the call stack. For large n, say nsum 10000, the computation might fail due to stack overflow.

      Iterative summing with accumulator: summing

      let rec summing n total =
        if n = 0 then total
        else summing (n - 1) (n + total)
      (* val summing : int -> int -> int = <fun> *)

      The additional argument total is a running total (called an accumulator). If n is zero, it returns the accumulator; otherwise it adds n and continues. The trace:

      summing 3 0 => summing 2 3
                  => summing 1 5
                  => summing 0 6
                  => 6

      The recursive calls do not nest — additions are done immediately as we go along. No stack of pending work builds up.

      Comparison

      FunctionRecursion patternStack usage
      nsumNested: work deferredGrows with n
      summingFlat: work done immediatelyConstant

      The arithmetic progression formula

      The sum can be computed in O(1) time using:

      1+2++n=n(n+1)21 + 2 + \dots + n = \frac{n(n+1)}{2}

      This is obviously the best approach if only the sum is needed. The recursive versions illustrate general patterns: naive recursion builds up deferred work, while accumulator-based iteration processes elements as it goes.

      When nesting matters

      The nesting in nsum is a specific instance of a general problem: if each recursive call must wait for the result of the next call, the intermediate values accumulate. Functions that avoid this are called iterative or tail-recursive.

    • Tail Recursion and Accumulators

      Definition of tail recursion

      A recursive function is tail-recursive (or iterative) if the recursive call is the last operation in the function — nothing remains to be done after it returns. The computation does not nest; each call’s work is completed before the next call begins.

      (* Not tail-recursive: addition happens after the recursive call *)
      let rec nsum n =
        if n = 0 then 0
        else n + nsum (n - 1)    (* + happens after nsum returns *)
      
      (* Tail-recursive: nothing after the recursive call *)
      let rec summing n total =
        if n = 0 then total
        else summing (n - 1) (n + total)   (* summing is the last thing *)

      Tail-recursive functions produce computations resembling those done by while-loops in conventional languages.

      Accumulators

      An accumulator is an extra argument that carries a running result through recursive calls:

      summing 3 0          (* start with accumulator = 0 *)
      => summing 2 3       (* accumulator updated to 3 *)
      => summing 1 5       (* accumulator updated to 5 *)
      => summing 0 6       (* accumulator updated to 6 *)
      => 6                 (* return final accumulator *)

      The accumulator replaces the need to “remember” intermediate results on the call stack. Each recursive call updates it and passes it forward.

      When to use accumulators (KISS)

      The KISS principle (Keep It Simple, Stupid) applies:

      • Write straightforward code first, avoiding only gross inefficiency
      • Do not add accumulators merely out of habit
      • If the program is too slow, profiling tools can pinpoint the cause

      For power (binary exponentiation), adding an accumulator makes it iterative but complicates the code. The gain is minute — at most 30 nested calls for 32-bit exponents. Not worth it.

      For nsum with n = 10000, the stack overflow risk is real. Worth it.

      Recursion vs iteration trade-offs

      RecursionIteration
      Natural for many algorithmsRequired in some languages
      Can express divide-and-conquerCan be awkward for tree/graph problems
      Stack overhead if not tail-recursiveConstant stack space

      Many algorithms are expressed naturally using recursion but only awkwardly using iteration. Dijkstra famously sneaked recursion into Algol-60 by inserting the words “any other occurrence of the procedure name denotes execution of the procedure” — carefully avoiding the word “recursion” to slip it past sceptical colleagues.

      Tail recursion and the compiler

      Tail-recursive code is only efficient if the compiler detects it and optimises accordingly (tail-call optimisation). OCaml performs this optimisation: a tail-recursive call reuses the same stack frame rather than allocating a new one. The main benefit is space (memory), though iterative code can sometimes also run faster.

    • Big O Notation

      Formal definition

      f(n)=O(g(n))f(n) = O(g(n)) means there exist constants cc and n0n_0 such that:

      f(n)cg(n)for all nn0|f(n)| \leq c \cdot |g(n)| \quad \text{for all } n \geq n_0

      In plain English: f(n)|f(n)| is bounded above by some constant multiple of g(n)|g(n)| for all sufficiently large n.

      What Big O captures

      The notation abstracts away details that do not affect asymptotic growth:

      • Constant factors: 100n2100n^2 will beat n3n^3 in the long run. Constants depend on hardware, OS, and language — ignoring them makes comparisons broadly valid.
      • Insignificant terms: If cost is n2+99n+900n^2 + 99n + 900, the n2n^2 term eventually dominates. The 99n99n term is bigger for n<99n < 99, but n2n^2 overtakes it.
      • Small values of n: The role of n0n_0 in the definition is to ignore finitely many exceptions where the bound does not hold.

      Simple facts

      Constant factors and insignificant terms disappear:

      O(2g(n)) is the same as O(g(n))O(2g(n)) \text{ is the same as } O(g(n)) O(log10n) is the same as O(lnn)O(\log_{10} n) \text{ is the same as } O(\ln n) O(n2+50n+36) is the same as O(n2)O(n^2 + 50n + 36) \text{ is the same as } O(n^2)

      For the last one: n2+50n+362n2n^2 + 50n + 36 \leq 2n^2 for n51n \geq 51, so we can double the constant factor and drop the lower-order terms.

      Containment relationships

      If cc and dd are constants with 0<c<d0 < c < d, then:

      • O(nc)O(nd)O(n^c) \subset O(n^d)
      • O(cn)O(dn)O(c^n) \subset O(d^n)
      • O(logn)O(nc)O(\log n) \subset O(n^c)

      The notation O(f(n))O(g(n))O(f(n)) \subset O(g(n)) means that if a function is O(f(n))O(f(n)), it is also O(g(n))O(g(n)) — the former gives a tighter bound. For example, if f(n)=O(2n)f(n) = O(2^n) then trivially f(n)=O(3n)f(n) = O(3^n), but the converse does not hold.

      Why constant factors are ignored

      • They seldom make a difference asymptotically: 100n2100n^2 beats n3n^3 for large n
      • They are unstable: different hardware, operating system, or language changes them
      • Ignoring them allows comparisons that remain valid across many circumstances

      Limitations of Big O

      Even ignoring constant factors, measurement units matter. Multiplication may be treated as one unit of cost, but multiplying two n-digit numbers for large n is itself expensive (relevant for cryptography). Few things are truly O(1): storing the number n requires O(log n) bits. An O(1) cost usually assumes operations fit within standard hardware arithmetic capacity.

    • Complexity Classes and Recurrences

      Common complexity classes

      Complexity classes

      ClassNameGrowth rate
      O(1)ConstantDoes not grow with input
      O(log n)LogarithmicGrows very slowly
      O(n)LinearProportional to input
      O(n log n)Quasi-linearSlightly worse than linear
      O(n²)QuadraticDoubling input quadruples cost
      O(n³)CubicDoubling input multiplies cost by 8
      O(2ⁿ)ExponentialEach additional input element doubles cost

      Logarithms grow very slowly — O(log n) is excellent. Because O notation ignores constant factors, the base of the logarithm is irrelevant.

      Growth rate table

      How large an input can be processed in a given time:

      Complexity1 second1 minute1 hourGain
      n100060,0003,600,000×60
      n log n1404,895204,095×41
      312441,897×8
      1039153×4
      2ⁿ91521+6

      The “gain” column shows what happens when going from 1 second to 1 minute. For exponential algorithms, the gain is a mere +6 — doubling hardware barely helps.

      Exponential vs polynomial

      • Polynomial complexity (O(nᶜ)): doubling the input increases cost by a constant factor. Practical for reasonable c, though c > 3 may still be problematic.
      • Exponential complexity (O(2ⁿ)): can never handle large inputs, even with huge resources. Already with n = 20 the cost exceeds one million.

      Faster hardware does not make good algorithms unnecessary. On the contrary, faster hardware magnifies the superiority of better algorithms: with sillySum (O(2ⁿ)), doubling processing power lets us go from n to n+1; with npower (O(n)), from n to 2n; with power (O(log n)), from n to n².

      Simple recurrence relations

      Simple recurrence relations and algorithm costs

      RecurrenceClosed formComplexity
      T(n+1) = T(n) + 1T(n) = nO(n)
      T(n+1) = T(n) + nT(n) = n(n-1)/2O(n²)
      T(n) = T(n/2) + 1T(2ⁿ) = n+1O(log n)
      T(n) = 2T(n/2) + n-O(n log n)

      These recurrences are read directly from OCaml definitions. The constant work in the recursive step gets unit cost; the base case also gets unit cost. Since we only need an upper bound, these units represent the larger of the actual costs.

    • Analysing Function Costs

      Summary of function complexities

      FunctionTimeSpaceNotes
      npowerO(n)O(n)n multiplications, not tail-recursive
      nsumO(n)O(n)n additions, not tail-recursive
      summingO(n)O(1)Iterative with accumulator
      n(n+1)/2O(1)O(1)Closed form
      powerO(log n)O(log n)Binary exponentiation
      sillySumO(2ⁿ)O(n)Calls itself twice per step

      Deriving recurrences from OCaml

      Consider nsum:

      let rec nsum n =
        if n = 0 then 0
        else n + nsum (n - 1)

      Given n+1, it performs constant work (addition and subtraction) and calls itself with n. The recurrence:

      T(0)=1T(0) = 1 T(n+1)=T(n)+1T(n+1) = T(n) + 1

      The closed form is T(n)=n+1T(n) = n + 1, giving O(n).

      nsumsum: O(n²)

      let rec nsumsum n =
        if n = 0 then 0
        else nsum n + nsumsum (n - 1)

      The recurrence: T(n+1)=T(n)+nT(n+1) = T(n) + n. Closed form:

      T(n)=(n1)++1=n(n1)2=O(n2)T(n) = (n-1) + \dots + 1 = \frac{n(n-1)}{2} = O(n^2)

      power: O(log n)

      power divides n by 2 each step: T(n)=T(n/2)+1T(n) = T(n/2) + 1. Since T(2n)=n+1T(2^n) = n+1, we have T(n)=O(logn)T(n) = O(\log n).

      sillySum: O(2ⁿ)

      let rec sillySum n =
        if n = 0 then 0
        else n + (sillySum (n-1) + sillySum (n-1)) / 2

      Each recursive step makes two recursive calls. The function calls itself 2ⁿ times total. This is exponential — doubling hardware lets us go from n to n+1, which is practically useless.

      The fix: use a let binding to compute a value once and reuse it:

      let x = 2.0 in
      let y = Float.pow x 20.0 in
      y *. (x /. y)

      Here y is computed once and used twice, avoiding the exponential blow-up.

      Space vs time

      • Space complexity can never exceed time complexity (it takes time to use space)
      • Time complexity often greatly exceeds space complexity
      • For example, sillySum is O(2ⁿ) time but only O(n) space (maximum call depth)
      • Tail recursion reduces space, not time
  • Lists

    The list type and its constructors nil ([]) and cons (::), the linked-list representation with O(1) head/tail, the null, hd, and tl primitives with polymorphic types and partial-match warnings, computing list length naively vs iteratively with an accumulator, the append function and its O(n) cost model, reversing a list in O(n²) using append vs O(n) using an accumulator, and the relationship between strings, characters, and lists

    • List Type and Constructors

      List syntax

      A list is an ordered series of elements; repetitions are significant. So [3; 5; 9] differs from [5; 3; 9] and from [3; 3; 5; 9]. Elements are separated with ; when constructing, as opposed to , syntax used for fixed-length tuples.

      [3; 5; 9]                     (* int list *)
      [(1, "one"); (2, "two")]      (* (int * string) list *)
      [[3]; []; [5; 6]]             (* int list list *)

      Type homogeneity

      All elements of a list must have the same type. If x1,,xnx_1, \dots, x_n all have type τ\tau, then [x1; ...; xn] has type τ\tau list.

      This contrasts with tuples, where (3, "hello") has type int * string and the elements may differ. The homogeneity property is checked at compile time, guaranteeing that no type errors occur at runtime when processing lists.

      Lists of lists

      Lists can contain lists as elements, forming nested structures:

      let nested = [[1; 2]; [3]; []; [4; 5; 6]]
      (* val nested : int list list *)

      The empty list [] can appear as an element of a list-of-lists. Each inner list is independently sized.

      concat (@) and List.rev

      The infix operator @ (also called List.append) concatenates two lists:

      [3; 5; 9] @ [2; 10]    (* [3; 5; 9; 2; 10] *)

      List.rev reverses a list:

      List.rev [(1, "one"); (2, "two")]    (* [(2, "two"); (1, "one")] *)

      Two kinds of lists

      There are exactly two kinds of list values:

      ConstructorMeaningExample
      []The empty list (nil)[]
      x :: lList with head x and tail l1 :: [2; 3]

      Every list is either empty, or a head element attached to a tail list. This recursive definition is the foundation of all list processing — functions over lists follow the same two-case structure:

      let rec f = function
        | [] -> ...        (* handle empty case *)
        | x :: xs -> ...   (* handle head x, recursive call on tail xs *)
    • Cons and Linked Representation

      The cons operator

      The operator :: (pronounced “cons”, short for “construct”) puts a new element onto the head of an existing list:

      1 :: [3; 5; 9]     (* [1; 3; 5; 9] *)
      1 :: 2 :: []        (* [1; 2] *)

      Critically, :: is an O(1) operation — it uses constant time and space regardless of the length of the list.

      Internal structure

      Lists are represented internally as a linked structure of cons cells. Each cons cell contains:

      • A reference to the head (an element)
      • A reference to the tail (another cons cell or [])

      Linked list cons cell structure

      Adding a new element to a list merely hooks a new cons cell to the front of the existing structure. The existing structure continues to denote the same list as it did before — to see the new list, one must look at the new cons cell.

      Sharing of tails

      Because cons does not copy the tail, multiple lists can share their tails:

      let xs = [3; 5; 9]
      let ys = 1 :: xs     (* ys = [1; 3; 5; 9], xs still = [3; 5; 9] *)

      ys and xs share the tail [3; 5; 9]. No copying occurred — only one new cons cell was allocated. This is a key efficiency property of functional lists: building a list with cons does not disturb existing lists.

      The cons diagram

      In the diagram notation:

      :: --> :: --> :: --> :: --> []
       |      |      |      |
       1      3      5      9

      Each :: is a cons cell. The rightward arrow points to the tail; the downward arrow points to the head. Taking the head or tail of a list is O(1) — each operation just follows one link.

      The tail is not the last element; it is the list of all elements other than the head. The tail of [1; 3; 5; 9] is [3; 5; 9], and the tail of [9] is [].

      Why this matters

      Understanding the linked representation explains:

      • Why cons is O(1): only one new cell allocated
      • Why head/tail are O(1): following one pointer
      • Why append is O(n): must copy the entire first list
      • Why sharing is safe: OCaml values are immutable — no one can modify a shared tail
    • Head, Tail, and Null

      The null function

      The function null tests whether a list is empty using pattern matching:

      let null = function
        | [] -> true
        | x :: l -> false
      (* val null : 'a list -> bool = <fun> *)

      The two clauses match the two possible forms of a list: empty ([]) or non-empty (x :: l). The function returns true only for the empty list.

      Head and tail access

      hd returns the first element of a non-empty list; tl returns the list of remaining elements:

      let hd (x :: l) = x
      (* val hd : 'a list -> 'a = <fun> *)
      
      let tl (x :: l) = l
      (* val tl : 'a list -> 'a list = <fun> *)

      Both functions raise a warning when compiled: the pattern matching is not exhaustive — the case [] is not handled. Applying hd or tl to [] raises an exception at runtime. The fix is to use null to check for emptiness beforehand, or better, to use proper two-case pattern matching.

      Partial match warnings

      OCaml’s compiler detects incomplete pattern matches. Two faulty declarations illustrate the problem:

      (* Handles only empty lists -- non-empty case missing *)
      let rec nlength [] = 0
      
      (* Handles only non-empty lists -- empty case missing *)
      let rec nlength (x :: xs) = 1 + nlength xs

      These are two separate declarations. The second let replaces the first rather than extending it. To handle both cases, use the function keyword with | to separate clauses.

      Polymorphic types

      The primitive list functions have polymorphic types using type variables:

      FunctionTypeMeaning
      null'a list -> boolWorks on any list
      hd'a list -> 'aHead matches element type
      tl'a list -> 'a listTail is same type of list

      The symbols 'a and 'b are type variables (pronounced “alpha”, “beta”, etc.) and stand for any type. Code using these functions is checked for type correctness at compile time, and this guarantees that at run time, all elements of a list have the same type.

      Pattern matching over hd and tl

      Taking a list apart using combinations of hd and tl is hard to get right and seldom necessary. Pattern matching with the function keyword is the preferred approach:

      let rec process = function
        | [] -> base_case
        | x :: xs -> ... x ... process xs ...

      This is safer (no missing cases if both are covered) and clearer (the structure of the list is visible in the pattern).

    • Computing Length

      Naive recursive length: nlength

      let rec nlength = function
        | [] -> 0
        | x :: xs -> 1 + nlength xs
      (* val nlength : 'a list -> int = <fun> *)

      This is polymorphic and applies to all lists regardless of element type. A trace:

      nlength [a; b; c] => 1 + nlength [b; c]
                        => 1 + (1 + nlength [c])
                        => 1 + (1 + (1 + nlength []))
                        => 1 + (1 + (1 + 0))
                        => 3

      Like nsum, this function is not tail-recursive. The additions nest — 1 + (1 + (1 + 0)) — requiring O(n) space on the call stack.

      Iterative length with accumulator: addlen

      let rec addlen n = function
        | [] -> n
        | x :: xs -> addlen (n + 1) xs
      (* val addlen : int -> 'a list -> int = <fun> *)

      The function keyword introduces an extra (unnamed) argument that is pattern-matched. The integer accumulator n tracks the count so far:

      addlen 0 [a; b; c] => addlen 1 [b; c]
                          => addlen 2 [c]
                          => addlen 3 []
                          => 3

      The recursive calls do not nest — this is iterative and uses O(1) space.

      Wrapper function

      The efficient length function wraps addlen with an initial accumulator of zero:

      let length xs = addlen 0 xs
      (* val length : 'a list -> int = <fun> *)

      Comparison

      FunctionRecursionTimeSpace
      nlengthNested (not tail-recursive)O(n)O(n)
      addlen/lengthIterative (tail-recursive)O(n)O(1)

      Both functions take O(n) time because you must examine each element to count it — there is no way around that. The accumulator improves space complexity only.

      Why nlength is “naive”

      The function nlength has the same flaw as nsum: it builds up deferred work (the 1 + expressions) that cannot be resolved until the base case is reached. For a list of length 10000, the call stack holds 10000 pending additions — a stack overflow waiting to happen. The iterative version addlen resolves the addition immediately (n + 1) and passes the result forward.

    • Append and Concatenation

      The append function

      let rec append xs ys =
        match xs, ys with
        | [], ys -> ys
        | x :: xs, ys -> x :: append xs ys
      (* val append : 'a list -> 'a list -> 'a list = <fun> *)

      The pattern matches on a pair of lists. Two cases:

      • If the first list is empty, return the second list unchanged
      • Otherwise, cons the head of the first list onto the result of appending its tail to the second list

      The infix operator @ is simply append:

      let (@) = append

      Evaluation trace

      append [1; 2; 3] [4] => 1 :: append [2; 3] [4]
                            => 1 :: (2 :: append [3] [4])
                            => 1 :: (2 :: (3 :: append [] [4]))
                            => 1 :: (2 :: (3 :: [4]))
                            => [1; 2; 3; 4]

      Complexity

      Append scans its first argument and sets up a chain of cons operations:

      • Time: O(n) where n is the length of the first argument
      • Space: O(n) — each element of the first list is copied into a new cons cell
      • Costs are independent of the second argument’s length

      The string of pending cons operations means this function is not iterative. Adding an accumulator could make it iterative but would not improve the asymptotic complexity — concatenation inherently requires copying the first list. At best, an accumulator decreases the constant factor, but complicating the code is likely to increase it. Never add an accumulator merely out of habit.

      Why the first list must be copied

      Because OCaml lists are immutable, append cannot modify the last cons cell of the first list to point to the second list. Instead, it must copy every cons cell of the first list, with the final copy pointing to the second list. The second list is shared, not copied:

      Original:  [1] --> [2] --> [3] --> []
      
      Copy:      [1] --> [2] --> [3] --> [4] --> []
                                        ^
                                        | (shared)
      Original:                  [4] --> []

      Polymorphic type

      Append’s type 'a list -> 'a list -> 'a list tells us two lists can be joined only if their element types agree. The result has the same element type.

    • Reversing Lists

      Naive reverse: nrev — O(n²)

      let rec nrev = function
        | [] -> []
        | x :: xs -> (nrev xs) @ [x]
      (* val nrev : 'a list -> 'a list = <fun> *)

      This reverses by recursively reversing the tail and appending the head as a singleton list at the end. A trace:

      nrev [a; b; c] => (nrev [b; c]) @ [a]
                     => ((nrev [c]) @ [b]) @ [a]
                     => (((nrev []) @ [c]) @ [b]) @ [a]
                     => (([] @ [c]) @ [b]) @ [a]
                     => [c; b; a]

      Why it is inefficient: each call to @ copies its first argument. For a list of length n:

      • Reverse the tail (n-1 elements): n-1 conses in append
      • Then cons x onto []: 1 cons
      • Total for this step: n conses
      • Reversing the tail requires n-1 more conses, and so forth

      Total conses: 0+1+2++n=n(n+1)2=O(n2)0 + 1 + 2 + \dots + n = \frac{n(n+1)}{2} = O(n^2)

      Space complexity is O(n) because the copies do not all exist at the same time.

      Efficient reverse: rev_app — O(n)

      let rec rev_app xs ys =
        match xs, ys with
        | [], ys -> ys
        | x :: xs, ys -> rev_app xs (x :: ys)
      (* val rev_app : 'a list -> 'a list -> 'a list = <fun> *)

      rev_app xs ys reverses the elements of xs and prepends them to ys. Trace:

      rev_app [a; b; c] [] => rev_app [b; c] [a]
                            => rev_app [c] [b; a]
                            => rev_app [] [c; b; a]
                            => [c; b; a]

      This performs exactly n conses for an n-element list — one per element as it moves from the input to the accumulator. Time is O(n), space is O(1) (tail-recursive).

      The wrapper hides the accumulator:

      let rev xs = rev_app xs []
      (* val rev : 'a list -> 'a list = <fun> *)

      Reduction in strength

      The key improvement comes from replacing an expensive operation (@, which is O(n)) with a series of cheap operations (::, which is O(1)). This technique is called reduction in strength and is a common pattern in algorithm design. It originated when hardware lacked multiply instructions — repeated addition was cheaper than multiplication.

      Why consing to an accumulator reverses

      Cons adds to the front of a list, so as elements are consed to the accumulator in order, the accumulator builds up in reverse:

      StepInput remainingAccumulator
      Start[a; b; c][]
      After a[b; c][a]
      After b[c][b; a]
      After c[][c; b; a]

      If the reverse order in the accumulator is undesirable, an extra call to rev at the end fixes it — but that doubles the cost. Sometimes the iterative function can be slower than the recursive one if an extra reversal is needed.

  • More on Lists

    The take and drop utilities for dividing lists, linear search and its O(n) time complexity (vs ordered search at O(log n) and indexed at O(1)), polymorphic equality and the member function, the zip and unzip functions for building and taking apart lists of pairs, and the making-change problem used as a case study in greedy algorithms (finding one solution), exhaustive search (all solutions), and stepwise refinement with accumulating parameters for speed

    • Take and Drop

      Splitting a list

      take and drop divide a list into two parts at a given index i. Given a list xs = [x₀, ..., xᵢ₋₁, xᵢ, ..., xₙ₋₁], take i xs returns the first i elements while drop i xs returns everything from position i onwards.

      Both functions share the same type signature:

      int -> 'a list -> 'a list

      take

      take copies the first i elements into a new list. It is not tail-recursive, but making it iterative would not improve its efficiency: copying i elements inherently requires O(i) time and space.

      let rec take i = function
        | [] -> []
        | x::xs ->
            if i > 0 then x :: take (i - 1) xs
            else []
      PropertyValue
      Time complexityO(i)
      Space complexityO(i)
      Tail-recursive?No

      drop

      drop simply skips over the first i elements. It is tail-recursive and uses only O(1) space.

      let rec drop i = function
        | [] -> []
        | x::xs ->
            if i > 0 then drop (i-1) xs
            else x::xs
      PropertyValue
      Time complexityO(i)
      Space complexityO(1)
      Tail-recursive?Yes

      Although both are O(i) in time, drop is much faster than take in practice because skipping elements is cheaper than copying them: drop has a smaller constant factor.

      Applications

      take and drop are used throughout the course for divide-and-conquer algorithms. A common pattern: divide a list into two roughly equal halves for recursive processing, as seen in mergesort where the input is split at the midpoint using take and drop.

    • Linear Search

      Linear search finds a target value in a collection by comparing it against each element in turn. If the target appears in the list, it is found in n/2 steps on average; the worst case requires checking all n elements.

      Performance

      Search methodTime complexityRequirements
      Linear searchO(n)Equality only
      Ordered search (binary)O(log n)Total ordering
      Indexed lookup (hash)O(1)Hash function

      Linear search is the most general: it needs only the ability to test equality between keys. Ordered search and indexed lookup are exponentially faster but impose additional constraints on the data.

      • Small collections: the constant factors are low and the code is simple.
      • Unordered data: when sorting or indexing would be more expensive than the search itself.
      • Prototyping: it is the natural starting point before optimising with better algorithms.

      For large, frequently searched datasets, linear search is rarely acceptable. Consider that a web search engine scanning billions of pages linearly would be impossibly slow; it is only through indexing that sub-second queries are possible. Nevertheless, linear search remains the conceptual foundation on which more sophisticated search algorithms are built.

    • Equality Tests and Member

      Polymorphic equality

      OCaml provides polymorphic equality operators that work across many types without requiring explicit type-specific comparison functions:

      OperatorMeaning
      =Structural equality
      <>Structural inequality
      <Less than (structural ordering)
      <=Less than or equal
      >Greater than
      >=Greater than or equal

      These operators inspect the structure of values and apply a consistent ordering.

      The member function

      member uses linear search with polymorphic equality to test whether a value appears in a list:

      let rec member x = function
        | [] -> false
        | y::l ->
           if x = y then true
           else member x l

      Its type, 'a -> 'a list -> bool, reflects that it works for any type that supports polymorphic equality.

      Which types support it?

      SupportedNot supported
      IntegersFunctions
      StringsRecursive structures (in general)
      BooleansAbstract types without comparison
      Tuples of primitive types
      Lists of primitive types

      Limitations and controversy

      Polymorphic equality cannot compare function values: two functions that compute the same result from identical source code may be stored at different memory addresses, and OCaml has no principled way to test functional equivalence.

      The presence of polymorphic equality is contentious in the OCaml community. For small programs and learning, it is convenient and safe enough. In large systems, however, its use can mask bugs: it may compare values that should not be compared, or impose structural ordering where none is meaningful. Many large-scale OCaml codebases avoid it in critical paths, preferring explicit comparison functions passed as arguments. For the purposes of this course, polymorphic equality is sufficient.

    • Zip and Unzip

      zip

      zip pairs up corresponding elements from two lists into a single list of pairs. If the lists differ in length, surplus elements from the longer list are silently discarded.

      let rec zip xs ys =
        match xs, ys with
        | (x::xs, y::ys) -> (x, y) :: zip xs ys
        | _ -> []

      Type: 'a list -> 'b list -> ('a * 'b) list

      Key points:

      • The wildcard pattern _ matches anything and signals that the second clause discards its arguments. Using _ is clearer than a named variable when the value is not used.
      • Pattern order matters: the first clause (x::xs, y::ys) is tried before the wildcard. Only when at least one list is empty does the second clause fire. If the clauses were reversed, zip would always return [].

      unzip

      unzip reverses the operation, taking a list of pairs and returning a pair of lists:

      let rec unzip = function
        | [] -> ([], [])
        | (x, y)::pairs ->
            let xs, ys = unzip pairs in
            (x::xs, y::ys)

      Type: ('a * 'b) list -> 'a list * 'b list

      The local let binding let xs, ys = unzip pairs in ... destructures the result of the recursive call. In general, let P = E₁ in E₂ matches pattern P against the value of expression E₁ and binds the variables in P within E₂.

      conspair helper

      An alternative version of unzip extracts the destructuring into a helper function:

      let conspair ((x, y), (xs, ys)) = (x::xs, y::ys)
      
      let rec unzip = function
        | [] -> ([], [])
        | xy :: pairs -> conspair (xy, unzip pairs)

      conspair takes a pair ((x, y), (xs, ys)) and returns (x::xs, y::ys). While not every local binding can be eliminated this cleanly, the pattern of factoring out helper functions is a useful technique for keeping code readable.

    • Making Change: The Greedy Algorithm

      Problem statement

      Given a till with unlimited supplies of coins in certain denominations (listed in descending order), find a combination of coins that sums to a target amount. The goal is to minimise the number of coins.

      Greedy approach

      The algorithm always tries the largest coin first: if the largest coin does not exceed the remaining amount, use it and recurse on the reduced amount. Otherwise, discard that coin denomination and try the next largest.

      let rec change till amt =
        match till, amt with
        | _, 0         -> []
        | [], _        -> raise (Failure "no more coins!")
        | c::till, amt -> if amt < c then change till amt
                          else c :: change (c::till) (amt - c)

      Key design decisions

      • Base case of zero, not one: when amt = 0, the result is [] (no coins needed). Using zero as the base case is simpler than using one; most iterative procedures are cleanest when they do nothing in the base case. A base case of one is often a sign of a novice programmer.
      • The coin is not removed from the till after use: the recursive call uses c::till (not till), since we have unlimited supply of each coin.
      • Failure via exceptions: if the till becomes empty while the amount is still nonzero, the function raises Failure.

      Greedy failure

      The greedy algorithm is not always correct. Example: making 6 using coins [5, 2].

      StepTillAmountAction
      1[5; 2]6Take 5 (largest ≤ 6)
      2[5; 2]15 > 1, skip 5
      3[2]12 > 1, skip 2
      4[]1Failure!

      Yet 6 = 2 + 2 + 2 is a valid solution. The greedy algorithm fails because it commits to the 5 coin and cannot backtrack. Greedy algorithms work for certain coin systems (e.g., UK coins: 1, 2, 5, 10, 20, 50) but not in general.

    • All Ways of Making Change

      Returning all solutions

      Instead of returning a single solution (or failing), we can return all possible ways of making change. The result type becomes int list list (a list of solutions, each solution a list of coins).

      Base version

      let rec change till amt =
        match till, amt with
        | _       , 0   -> [ [] ]
        | []      , _   -> []
        | c::till , amt -> if amt < c then change till amt
                           else let rec allc = function
                                   | [] -> []
                                   | cs :: css -> (c::cs) :: allc css
                                in
                                   allc (change (c::till) (amt - c)) @
                                         change till amt

      Key differences from the greedy version:

      • Success base case: [[]] (a list containing the empty solution), not just []. This represents “there is one way: use no coins”.
      • Failure base case: [] (empty list of solutions), rather than raising an exception.
      • Two sources of solutions: either use the current coin (change (c::till) (amt - c)) or skip it (change till amt). The locally declared allc function prepends the current coin c to each solution from the first source, and the results are concatenated with @.

      Stepwise refinement: faster version

      The base version uses many :: and @ operations. A refined version introduces accumulating parameters to eliminate them:

      let rec change till amt chg chgs =
        match till, amt with
        | _       , 0   -> chg::chgs
        | []      , _   -> chgs
        | c::till , amt -> if amt < 0 then chgs
                           else change (c::till) (amt - c) (c::chg)
                                       (change till amt chg chgs)
      ParameterRole
      chgAccumulates the coins chosen so far (replaces allc)
      chgsAccumulates the list of complete solutions (replaces @)

      This version runs several times faster than the base version. The technique of starting with a simple program and incrementally improving it is called stepwise refinement.

      Exponential blow-up

      Even the refined version cannot escape the fundamental problem: the number of solutions grows rapidly. Using UK coins (50, 20, 10, 5, 2, 1), there are 4,366 ways to make change for 99. No algorithm can list all solutions faster than the number of solutions, so the runtime remains exponential in the amount.

  • Sorting

    The information-theoretic lower bound of O(n log n) comparisons for comparison-based sorting, insertion sort as a simple O(n²) algorithm with linear insertion, quicksort via divide-and-conquer with a pivot (O(n log n) average case, O(n²) worst case with reverse-sorted input), eliminating append from quicksort with an accumulator, merging two sorted lists with at most m+n−1 comparisons, and top-down mergesort guaranteeing O(n log n) worst-case time via equal splitting

    • The Sorting Lower Bound

      Why sort?

      Sorting is one of the most deeply studied topics in algorithm design. Common applications:

      ApplicationWhy sorting helps
      SearchSorted data admits binary search: O(log n) vs. O(n)
      MergingTwo sorted lists can be merged in linear time
      DuplicatesAfter sorting, duplicates become adjacent and easy to find
      Inverting tablesA directory sorted by name can be re-sorted by phone number
      GraphicsMany rendering algorithms require sorted input

      Information-theoretic lower bound

      How many comparisons are needed to sort n items? Consider the decision tree model:

      • There are n! possible permutations of n distinct elements.
      • Each comparison eliminates roughly half of the remaining possibilities.
      • With C(n) comparisons, we can distinguish at most 2^{C(n)} outcomes.

      For the algorithm to be correct for all inputs, we must have:

      2C(n)n!2^{C(n)} \geq n!

      Taking logarithms:

      C(n)log2(n!)nlog2n1.44nC(n) \geq \log_2(n!) \approx n \log_2 n - 1.44n

      This is the information-theoretic lower bound: any comparison-based sorting algorithm must perform at least O(n log n) comparisons in the worst case. Mergesort achieves this bound exactly, making it asymptotically optimal.

      Beyond comparisons

      The lower bound applies only to comparison-based sorting. Non-comparison sorts (e.g., radix sort) can achieve O(n) time for certain restricted inputs, such as integers within a fixed range. This does not contradict the lower bound because such algorithms exploit properties of the data beyond pairwise comparisons.

    • Insertion Sort

      How it works

      Insertion sort builds the sorted output one element at a time by inserting each input element into its correct position among the already-sorted elements.

      ins inserts a single element into a sorted list:

      let rec ins x = function
        | [] -> [x]
        | y::ys -> if x <= y then x :: y :: ys
                   else y :: ins x ys

      On average, ins performs n/2 comparisons to insert into a list of length n.

      insort applies ins to each element of the input:

      let rec insort = function
        | [] -> []
        | x::xs -> ins x (insort xs)

      Complexity

      MeasureValue
      Average comparisonsO(_n_²)
      Worst-case comparisonsO(_n_²)
      Tail-recursive?No, but that is not the bottleneck

      The quadratic runtime makes insertion sort nearly useless for large inputs. On the DECstation benchmarks referenced in the course, insertion sort took 174 seconds to sort 10,000 random numbers, while quicksort took 0.74 seconds: over 200 times slower.

      Why study it?

      Insertion sort is easy to code and illustrates fundamental concepts:

      • It is the natural first attempt at a sorting algorithm.
      • Mergesort and heapsort can be understood as refinements of insertion sort.
      • For very small lists (length < 5 or so), insertion sort’s low constant factors can make it the fastest choice, and production quicksort implementations often switch to insertion sort for small partitions.

      The bottleneck is the number of comparisons (O(_n_²)), not the absence of tail recursion. Making insertion sort iterative would not change its asymptotic complexity.

    • Quicksort

      Divide, conquer, combine

      Quicksort (invented by Sir Tony Hoare) follows the divide-and-conquer paradigm:

      1. Choose a pivot element a from the input.
      2. Divide: partition the remaining elements into those ≤ a and those > a.
      3. Conquer: recursively sort each partition.
      4. Combine: append the sorted left partition to the pivot followed by the sorted right partition.

      Quicksort partition

      List-based implementation

      let rec quick = function
        | [] -> []
        | [x] -> [x]
        | a::bs ->
             let rec part l r = function
               | [] -> (quick l) @ (a :: quick r)
               | x::xs ->
                   if (x <= a) then
                     part (x::l) r xs
                   else
                     part l (x::r) xs
              in
              part [] [] bs

      Three clauses:

      • Empty list: already sorted.
      • Singleton list [x]: already sorted (this special case avoids unnecessary work).
      • Two or more elements: use the head a as pivot, partition the tail bs.

      The locally declared part function accumulates elements ≤ pivot in l and elements > pivot in r.

      Complexity analysis

      CaseRecurrenceComplexity
      Average caseT(n) = 2_T_(n/2) + nO(n log n)
      Worst caseT(n+1) = T(n) + nO(_n_²)

      Average case: with random data, the pivot typically divides the input into roughly equal halves. On the course benchmarks, quicksort sorts 10,000 random numbers in ~0.74 seconds.

      Worst case: when the input is already sorted or reverse-sorted, nearly all elements fall into one partition. The work is not divided evenly, and complexity degenerates to quadratic. Randomising the input (or choosing a random pivot) makes the worst case highly unlikely in practice.

    • Append-Free Quicksort

      Eliminating append

      The standard quicksort uses @ to combine sorted partitions in the combine phase. This can be eliminated using an accumulating parameter sorted, following the same technique used to remove @ from list reversal.

      let rec quik = function
        | ([], sorted) -> sorted
        | ([x], sorted) -> x::sorted
        | a::bs, sorted ->
           let rec part = function
             | l, r, [] -> quik (l, a :: quik (r, sorted))
             | l, r, x::xs ->
                 if x <= a then
                   part (x::l, r, xs)
                 else
                   part (l, x::r, xs)
           in
           part ([], [], bs)

      How it works

      Calling quik(xs, sorted) reverses the elements of xs and prepends them to sorted. The evaluation order inside part is significant:

      1. quik (r, sorted) is performed first (sort the right partition and prepend to sorted).
      2. The pivot a is consed onto that result.
      3. quik (l, ...) sorts the left partition and prepends it.

      The sorted parameter accumulates the result, replacing the repeated concatenation (quick l) @ (a :: quick r).

      Speed comparison

      The append-free version is significantly faster. An imperative Pascal quicksort (using in-place array swaps, from Sedgewick) is only slightly faster than quik. This near-agreement is surprising given that linked lists have more overhead than arrays. In realistic applications, comparisons dominate the runtime, and list overhead matters less than one might expect.

      On the DECstation benchmarks, append-free quicksort sorts 10,000 random numbers in approximately 0.53 seconds, compared to 0.74 seconds for the append-based version.

    • Merge and Mergesort

      merge

      merge combines two already sorted lists into a single sorted list. It generalises insertion to two lists:

      let rec merge = function
        | [], ys -> ys
        | xs, [] -> xs
        | x::xs, y::ys ->
            if x <= y then
              x :: merge (xs, y::ys)
            else
              y :: merge (x::xs, ys)

      At each step, merge compares the heads of both lists and takes the smaller. It does at most m + n − 1 comparisons, where m and n are the lengths of the input lists. If n = 1, merging degenerates to insertion: it does proportionally more work for less gain.

      merge is not tail-recursive, but making it iterative would offer little benefit for the same reasons that apply to append.

      Top-down mergesort

      Mergesort divides the input into two equal halves (unlike quicksort, which divides by value), sorts each half recursively, and merges the sorted halves:

      let rec tmergesort = function
        | [] -> []
        | [x] -> [x]
        | xs ->
            let k = List.length xs / 2 in
            let l = tmergesort (take k xs) in
            let r = tmergesort (drop k xs) in
            merge (l, r)

      Mergesort recursion tree

      Complexity

      Because take and drop divide the input into equal parts (differing by at most one element), the recurrence is always T(n) = 2_T_(n/2) + n, giving guaranteed O(n log n) in the worst case. Mergesort is asymptotically optimal.

      Comparison with quicksort

      AlgorithmAverage caseWorst caseTypical speed
      QuicksortO(n log n)O(_n_²)~0.74 sec
      Append-free quicksortO(n log n)O(_n_²)~0.53 sec
      MergesortO(n log n)O(n log n)~1.4 sec

      Mergesort is safe (no quadratic worst case) but typically slower than quicksort on random data. The choice of algorithm depends on the application: if worst-case behaviour matters (e.g., real-time systems or adversarial inputs), mergesort is preferable. If average-case performance is the priority and inputs are not expected to be pathologically ordered, quicksort is the better choice.

  • Datatypes and Trees

    Datatype declarations: enumeration types (e.g. vehicle with Bike, Car, Lorry), constructors with polymorphic arguments, pattern-matching with wildcards and nested patterns, error handling with exceptions (raise and try-with), the option type as an alternative to exceptions (Some x vs None), the making-change problem revisited with backtracking via exceptions, binary trees as the recursive datatype 'a tree = Lf | Br of 'a * 'a tree * 'a tree, basic tree properties (count, depth, leaves), and the relationship count(t) = leaves(t) − 1

    • Enumeration Types

      Declaring a new type

      OCaml’s type declaration introduces new types beyond the built-in ones. An enumeration type is a type defined by listing its possible values (called constructors):

      type vehicle = Bike | Motorbike | Car | Lorry

      This declaration:

      • Creates a new type called vehicle.
      • Creates four new constants (Bike, Motorbike, Car, Lorry) of that type.
      • The constructors serve both as values and as patterns for pattern matching.

      Pattern matching on constructors

      Once declared, the new type behaves like a built-in type:

      let wheels = function
        | Bike -> 2
        | Motorbike -> 2
        | Car -> 4
        | Lorry -> 18

      The type of wheels is vehicle -> int. The compiler checks that all cases are covered and warns about missing or redundant patterns.

      Why not use integers or strings?

      ApproachProblem
      Integers (0, 1, 2, 3)Code is hard to read; adding a new constructor may shift all numbers
      Strings (“Bike”, “Car”, …)Comparisons are slow; typos like “MOtorbike” are silent bugs
      Enumeration typeCompiler catches missing cases and typos; values are distinct types

      The compiler chooses the internal integer representation automatically. Type safety prevents confusion between different enumeration types: Bike and Red (from a colour type) are distinct and cannot be accidentally interchanged.

      Most programming languages support enumeration types. Common examples include days of the week, colours, and states in a state machine.

    • Constructors with Arguments

      Carrying data

      OCaml generalises enumeration types by allowing constructors to carry data:

      type vehicle = Bike
                   | Motorbike of int   (* engine size in CCs *)
                   | Car       of bool  (* true if a Reliant Robin *)
                   | Lorry     of int   (* number of wheels *)
      • Bike is a constant constructor: it is a value by itself.
      • Motorbike 450 is a vehicle constructed from the integer 450.
      • Car true and Car false are distinct vehicles from the same constructor.
      • Motorbike of int and Lorry of int are distinct types: even though both carry an int, OCaml tracks which constructor was used. Motorbike 450 is never confused with Lorry 450.

      Pattern matching with arguments

      Constructors carrying data can be destructured in patterns:

      let wheels = function
        | Bike -> 2
        | Motorbike _ -> 2           (* discard the engine size *)
        | Car robin -> if robin then 3 else 4  (* bind to robin *)
        | Lorry w -> w               (* extract the wheel count *)

      Pattern elements:

      • Named variable (robin, w): binds the carried value for use in the right-hand side.
      • Wildcard _: matches any carried value and discards it. The underscore signals that the argument is irrelevant to the computation.

      Mixed-type-like lists

      Datatypes with constructors carrying different payload types allow heterogeneous collections:

      [Bike; Car true; Motorbike 450]

      This is a vehicle list. It behaves almost like a mixed-type list containing integers and booleans. The datatype mechanism reduces the impact of the restriction that all list elements must share the same type.

      Nested patterns

      Patterns can be as complex as needed and may combine constructors from multiple datatypes. For example, patterns can deconstruct lists and vehicles simultaneously, or match integer and string constants inside constructors. OCaml compiles pattern matching efficiently.

    • Exceptions and the Option Type

      Exception declarations

      Exceptions are declared like constructors of a special built-in type exn:

      exception Failure
      exception NoChange of int
      • Failure is a simple exception carrying no data.
      • NoChange 6 carries an integer explaining which amount could not be changed.

      Each exception declaration introduces a distinct sort of error that can be caught separately.

      Raising and handling

      • raise abandons the current computation and propagates the exception upward.
      • try ... with catches exceptions and attempts an alternative computation.
      try
        print_endline "pre exception";
        raise (NoChange 1);
        print_endline "post exception";
      with
        | NoChange _ -> print_endline "handled a NoChange exception"

      The output is pre exception followed by handled a NoChange exception. The code after raise is never reached. The handler matches exceptions using pattern matching, and only the most recently entered handler that matches is invoked.

      Dynamic matching

      Exception handlers are resolved dynamically (at runtime). This contrasts with how OCaml resolves variable bindings, which happens statically (at compile time). A function can raise an exception deep in a call chain, and the handler might be in a completely different part of the program.

      Comparison with Java

      OCaml’s exceptions have no checked exception mechanism: nothing in a function’s type indicates which exceptions it might raise. In Java, checked exceptions must be declared in the method signature. This is a trade-off: OCaml gives more flexibility at the cost of less static documentation of failure modes.

      The option type

      An alternative to exceptions is the option type:

      type 'a option = None | Some of 'a
      MechanismSuccessFailure
      ExceptionsNormal returnraise
      Option typeSome xNone

      The option type makes failure explicit in the type: a function returning 'a option clearly signals that it may fail. The drawback is that every caller must check for None, which can clutter the code.

      When to use each

      • Exceptions: when failure is truly exceptional and the normal control flow should not be littered with error checks.
      • Option: when failure is a routine outcome that callers should handle explicitly.
    • Making Change with Exceptions (Backtracking)

      The problem with greed

      The greedy change algorithm fails when the largest-coin-first strategy leads to a dead end (e.g., making 6 from [5, 2]). We need a way to undo a coin choice and try alternatives: this is backtracking.

      Exception-driven backtracking

      The idea is elegant: enclose each recursive call in an exception handler. If the recursive call fails (raises Change), the handler catches it and tries the next alternative.

      exception Change
      
      let rec change till amt =
        match till, amt with
        | _, 0         -> []
        | [], _        -> raise Change
        | c::till, amt -> if amt < 0 then raise Change
                          else try c :: change (c::till) (amt - c)
                               with Change -> change till amt

      How it works

      • If amt = 0: success, return [].
      • If the till is empty or amt goes negative: dead end, raise Change.
      • Otherwise: try using the largest coin. If the recursive call raises Change, catch it and try the next coin instead.

      The exception handler always undoes the most recent choice. If all alternatives fail, Change propagates to the previous handler, and so on. If change is truly impossible, Change reaches the top level uncaught.

      Trace: change [5; 2] 6

      change [5; 2] 6
      → try 5::change [5; 2] 1
      → try 5::(try 5::change [5; 2] (-4)    ← amt < 0, raise Change
                 with Change → change [2] 1)
      → try 5::(try 2::change [2] (-1)       ← amt < 0, raise Change
                 with Change → change [] 1)   ← empty till, raise Change
      → change [2] 6                          ← handler caught it, try [2]
      → try 2::change [2] 4
      → try 2::(try 2::change [2] 2
      → try 2::(try 2::(try 2::change [2] 0  ← amt = 0, success: []
                 with Change → change [] 2)
      → [2; 2; 2]                             ← success!

      Observe how handlers nest as the recursion deepens, and drop away once a branch succeeds.

      Comparison with list-of-all-solutions

      ApproachReturnsSpaceUse case
      All solutions (list)All possibilitiesExponentialWhen all solutions are needed
      Backtracking (exceptions)First solution foundO(recursion depth)When one solution suffices

      Backtracking is dramatically more space-efficient and faster when only one solution is needed, since it stops searching upon success and does not accumulate partial results.

    • Binary Trees

      Recursive datatype definition

      A binary tree is a recursive datatype where each node is either empty or holds a label and two subtrees:

      type 'a tree =
        Lf
      | Br of 'a * 'a tree * 'a tree
      • Lf (leaf) represents an empty tree.
      • Br(v, t1, t2) is a branch node with label v, left subtree t1, and right subtree t2.
      • The type parameter 'a makes it polymorphic: an int tree stores integers, a string tree stores strings, etc.

      Binary tree structure

      Constructing a tree

      Br(1, Br(2, Br(4, Lf, Lf),
                   Br(5, Lf, Lf)),
             Br(3, Lf, Lf))

      This builds a tree with root label 1, left child 2 (with children 4 and 5), and right child 3. All leaves are Lf.

      Lists as a recursive datatype

      Built-in OCaml lists could be declared as a user-defined type:

      type 'a mylist =
        Nil
      | Cons of 'a * 'a mylist

      This mirrors the built-in [] and :: exactly. The only thing not reproducible is the [a; b; c] syntactic sugar, which is part of the OCaml grammar.

      Other recursive datatypes

      Not all datatypes need to be both recursive and polymorphic:

      TypeRecursive?Polymorphic?Example
      'a treeYesYesBinary trees with arbitrary labels
      shapeYesNoNull | Join of shape * shape
      'a optionNoYesNone | Some of 'a

      A shape value represents the structure of a binary tree with no data attached. The option type is polymorphic (useful for any type) but not recursive (it does not contain further options).

    • Tree Properties

      Basic measures

      Three functions characterise the size and shape of a binary tree:

      count — the number of branch (Br) nodes:

      let rec count = function
        | Lf -> 0
        | Br (v, t1, t2) -> 1 + count t1 + count t2

      depth — the length of the longest path from root to a leaf:

      let rec depth = function
        | Lf -> 0
        | Br (v, t1, t2) -> 1 + max (depth t1) (depth t2)

      leaves — the number of leaf (Lf) nodes:

      let rec leaves = function
        | Lf -> 1
        | Br (v, t1, t2) -> leaves t1 + leaves t2

      Fundamental invariants

      These measures are related by two properties, provable by structural induction:

      InvariantMeaning
      leaves(t) = count(t) + 1Every tree has exactly one more leaf than branch node
      count(t) ≤ 2^(depth(t)) − 1A tree of given depth can hold at most a full binary tree’s worth of nodes

      The second invariant gives a striking practical fact: a tree of depth 20 can store up to 2²⁰ − 1 ≈ one million elements. Access paths to any element are at most 20 steps long, compared with up to one million steps in a linear list.

      ftree: building labelled complete trees

      let rec ftree k n =
        if n = 0 then Lf
        else Br (k, ftree (2 * k) (n - 1), ftree (2 * k + 1) (n - 1))

      ftree k n builds a complete binary tree of depth n where nodes are labelled according to the standard array-to-tree mapping: the root gets label k, the left child gets 2_k_, and the right child gets 2_k_ + 1. This labelling scheme is the foundation for functional arrays (Braun trees), which are covered in the following lecture.

  • Dictionaries and Functional Arrays

    The dictionary abstraction with lookup, update, delete, and empty operations, association lists as the simplest (but O(n)) dictionary implementation, binary search trees (BSTs) for ordered keys (O(log n) average case), BST lookup navigating left or right by key comparison, functional BST update that copies only the path (O(log n) space), the three tree traversal orders (preorder, inorder, postorder) and why naive versions are quadratic, efficient traversal using accumulating parameters, and functional arrays as Braun trees where subscripts map to binary paths for guaranteed O(log n) access

    • Dictionaries and Association Lists

      Dictionary operations

      A dictionary (or map) associates keys with values. The core operations are:

      OperationDescription
      lookupFind the value associated with a key
      updateInsert or replace a key-value pair
      deleteRemove a key-value pair
      emptyThe dictionary containing no entries
      MissingException raised when a key is not found

      An abstract type would hide the internal representation behind these operations. This course does not cover OCaml’s module system, so operations are declared individually.

      Association list

      The simplest representation is an association list: a list of (key, value) pairs.

      lookup

      Linear search through the list:

      exception Missing
      
      let rec lookup a = function
        | [] -> raise Missing
        | (x, y) :: pairs ->
            if a = x then y
            else lookup a pairs

      O(n) time, where n is the number of entries.

      update

      Simply cons the new pair onto the front:

      let update (l, b, y) = (b, y) :: l

      O(1) time, which is optimal.

      Limitations

      AspectBehaviour
      Lookup speedO(n) — slow for large dictionaries
      Update speedO(1) — fast
      Space usageGrows with number of updates, not number of distinct keys
      Ordering required?No — only equality on keys

      The space problem is significant: obsolete entries accumulate and are never removed. Deleting an entry would require finding it first, which would increase update from O(1) to O(n). Association lists are acceptable only when there are few keys in use, or when fast updates matter more than fast lookups.

    • Binary Search Trees

      The BST invariant

      A binary search tree (BST) is a binary tree where each branch node holds a (key, value) pair, and the keys satisfy:

      • All keys in the left subtree are less than the node’s key.
      • All keys in the right subtree are greater than the node’s key.
      • This property holds recursively at every node.

      Binary search tree

      BSTs require a total ordering on keys (e.g., numeric or lexicographic order). This is a stronger requirement than association lists, which need only equality.

      Balanced vs. unbalanced

      Tree shapeLookup timeExample cause
      BalancedO(log n)Random insertion order
      Unbalanced (degenerate)O(n)Insertions in sorted order

      Building a BST by repeatedly inserting elements in increasing or decreasing order produces a degenerate tree that is essentially a linked list. This is analogous to quicksort’s worst case: the pivot (root key) does not divide the data evenly.

      Treesort

      If we insert all elements into a BST and then perform an inorder traversal, the result is a sorted list. This algorithm is called treesort. Its complexity mirrors that of quicksort: O(n log n) average case, O(_n_²) worst case.

      Self-balancing trees

      Self-balancing trees (e.g., Red-Black trees, AVL trees) maintain the O(log n) guarantee in the worst case by automatically restructuring the tree after insertions and deletions. They are more complex to implement but are the standard choice for production dictionary data structures. The course mentions them but does not cover their implementation.

    • BST Lookup and Update

      lookup

      lookup navigates the tree by comparing the sought key against each node’s key:

      exception Missing of string
      
      let rec lookup b = function
        | Br ((a, x), t1, t2) ->
            if b < a then
              lookup b t1
            else if a < b then
              lookup b t2
            else
              x
        | Lf -> raise (Missing b)

      Three cases at each branch:

      • Smaller: search left subtree.
      • Greater: search right subtree.
      • Equal: return the stored value.
      • Leaf reached: the key is not present; raise Missing.

      In a balanced tree, lookup is O(log n). The exception is parameterised with the missing key so the caller knows what was sought.

      update

      update follows the same search path but reconstructs the tree along the way:

      let rec update k v = function
        | Lf -> Br ((k, v), Lf, Lf)
        | Br ((a, x), t1, t2) ->
            if k < a then
              Br ((a, x), update k v t1, t2)
            else if a < k then
              Br ((a, x), t1, update k v t2)
            else (* a = k *)
              Br ((a, v), t1, t2)

      Functional update: path copying

      The key insight is that update does not modify the existing tree in place. Instead, it creates a new tree that shares unchanged subtrees with the original:

      CaseAction
      k < aUpdate left subtree; share right subtree unchanged
      k > aUpdate right subtree; share left subtree unchanged
      k = aReplace value only; share both subtrees unchanged

      Only the nodes on the path from the root to the updated node are copied. Unchanged subtrees are shared between the old and new trees. For a balanced tree, this means O(log n) time and O(log n) extra space per update. This is a classic example of persistent (or purely functional) data structures: old versions remain valid and accessible after updates.

    • Tree Traversals

      Three depth-first orders

      Tree traversal means visiting every node in a systematic order. Knuth identifies three depth-first traversals, all of which process the left subtree before the right subtree. They differ only in when the node’s label is visited:

      TraversalOrderMnemonicApplication
      PreorderNode, Left, RightNLRPolish notation
      InorderLeft, Node, RightLNRSorted output for BSTs
      PostorderLeft, Right, NodeLRNReverse Polish notation

      Tree traversals

      Naive implementations

      Each traversal converts a tree into a list of labels:

      let rec preorder = function
        | Lf -> []
        | Br (v, t1, t2) ->
            [v] @ preorder t1 @ preorder t2
      
      let rec inorder = function
        | Lf -> []
        | Br (v, t1, t2) ->
            inorder t1 @ [v] @ inorder t2
      
      let rec postorder = function
        | Lf -> []
        | Br (v, t1, t2) ->
            postorder t1 @ postorder t2 @ [v]

      Example: given a tree with root A, left child B (children D, E), right child C (children F, G):

      TraversalResult
      PreorderABDECFG
      InorderDBEAFCG
      PostorderDEBFGCA

      Inorder on BSTs

      Applying inorder to a binary search tree yields the keys in sorted order. This property is the basis of treesort: build a BST from the input, then traverse inorder to get the sorted list.

      Inefficiency

      All three naive traversals suffer from the same problem: repeated uses of @ (append). In the worst case (a degenerate tree), the time complexity is O(_n_²) because each @ copies its first argument. The next note shows how to fix this with accumulating parameters.

    • Efficient Tree Traversal

      The problem with append

      The naive traversal functions use @ in recursive calls. In a degenerate (unbalanced) tree, this leads to O(_n_²) total time because each append copies the left result. The fix follows the same pattern used throughout the course: introduce an accumulating parameter to eliminate @.

      Efficient versions

      Each function takes a pair (tree, accumulator), where the accumulator vs collects the result:

      let rec preord = function
        | Lf, vs -> vs
        | Br (v, t1, t2), vs ->
            v :: preord (t1, preord (t2, vs))
      
      let rec inord = function
        | Lf, vs -> vs
        | Br (v, t1, t2), vs ->
            inord (t1, v::inord (t2, vs))
      
      let rec postord = function
        | Lf, vs -> vs
        | Br (v, t1, t2), vs ->
            postord (t1, postord (t2, v::vs))

      All three are O(n) in time for a tree of size n. They use cons (::) instead of append (@), a classic example of reduction in strength: replacing an expensive operation with a series of cheap ones.

      Relating efficient and naive versions

      The efficient versions satisfy equations that connect them to the naive ones. For example:

      inord(t, vs) = inorder(t) @ vs

      This identity means that inord computes the same result as the naive inorder followed by append, but does so in linear time rather than quadratic.

      All three are depth-first

      Preorder, inorder, and postorder are all depth-first traversals: each traverses the entire left subtree before touching the right subtree. An alternative is breadth-first traversal, which visits nodes level by level, covered in the Queues lecture.

    • Functional Arrays

      Conventional vs. functional arrays

      PropertyConventional arrayFunctional array
      UpdateIn-place: a.(k) <- xReturns new array: update(A, k, x)
      Old versionDestroyedPersists unchanged
      StyleImperativePure functional
      LookupO(1) (hardware)O(log n)

      Functional arrays are a finite map from integers to data, where every update produces a new array rather than mutating the existing one. The challenge is doing this efficiently without copying the entire array.

      Braun trees

      The representation, credited to W. Braun, stores array elements in a balanced binary tree where the path to element i is determined by the binary representation of i:

      Functional array tree

      The subscript arithmetic:

      if k = 1        → the root
      if k mod 2 = 0  → go left, then recurse on k/2
      if k mod 2 = 1  → go right, then recurse on k/2

      Equivalently: write k in binary, discard the leading 1, reverse the remaining bits, and interpret 0 as “go left” and 1 as “go right”. This scheme guarantees a balanced tree, ensuring O(log n) access for all subscripts.

      sub: array lookup

      exception Subscript
      
      let rec sub = function
        | Lf, _ -> raise Subscript
        | Br (v, t1, t2), k ->
            if k = 1 then v
            else if k mod 2 = 0 then
              sub (t1, k / 2)
            else
              sub (t2, k / 2)

      Divides the subscript by 2 repeatedly until reaching 1. At each step, the parity determines which subtree to follow. If a leaf is reached before subscript 1, the position does not exist and Subscript is raised.

      update: functional array update

      let rec update = function
        | Lf, k, w ->
            if k = 1 then
              Br (w, Lf, Lf)        (* extend the array *)
            else
              raise Subscript        (* gap in the tree *)
        | Br (v, t1, t2), k, w ->
            if k = 1 then
              Br (w, t1, t2)         (* replace root value *)
            else if k mod 2 = 0 then
              Br (v, update (t1, k / 2, w), t2)
            else
              Br (v, t1, update (t2, k / 2, w))

      Two cases for k = 1:

      • At a leaf: extend the array by creating a new branch (the array grows).
      • At a branch: replace the existing value (update in place functionally).

      Like BST update, only the path from root to the target index is copied, giving O(log n) time and space. Unchanged subtrees are shared.

      Why binary?

      The subscript arithmetic works because binary decisions (left/right) reduce the search space by half at each step. This is the fundamental reason binary is important in computer science: an if-then-else leads to binary branching, which turns an O(n) cost into O(log n). Two is the smallest integer divisor that achieves this reduction.

  • Functions as Values

    Higher-order functions: passing functions as arguments, returning them as results, and storing them in data structures, anonymous functions with fun-notation vs the function keyword, currying (transforming multi-argument functions into nested single-argument functions), the syntactic shorthand for curried definitions, partial application and its uses (e.g. fixing the first argument), the map functional (apply-to-all), matrix transpose and matrix multiplication expressed elegantly with map and currying, and predicate functionals (exists, all, filter) with applications to member, inter, and disjoint

    • Functions as Values

      Functions as first-class values

      In OCaml, functions can be:

      • passed as arguments to other functions
      • returned as results from other functions
      • put into data structures like lists, trees, and tuples
      • but not tested for equality (comparing function addresses would test identity, not equivalence)
      [(fun n -> n * 2); (fun n -> n * 3); (fun n -> n + 1)]
      (* : (int -> int) list = [<fun>; <fun>; <fun>] *)

      This is a list of three functions, each of type int -> int. Storing computational behaviour in data structures is a powerful abstraction that reaches its full realisation in object-oriented programming.

      Higher-order functions (functionals)

      A functional or higher-order function is a function that operates on other functions - either by taking them as arguments or returning them as results. The name comes from mathematics: the differential operator maps functions to their derivatives.

      ConceptIn mathematicsIn OCaml
      FunctionInfinite uncomputable mappingComputable algorithm
      FunctionalE.g. differentiation operatormap, filter, exists

      Why functions as values matter

      Progress in programming languages can be measured by what abstractions they admit. Conditional expressions replaced numeric-sign-based jumps. Parametric types like 'a list gave us type-safe generics. Functions-as-values is the next step: it captures common program structures once and for all.

      • Avoids writing repetitive recursive functions for similar patterns
      • Lets you define ad-hoc operations inline without naming them
      • Enables libraries of combinators (map, filter, fold, etc.) that generalise across many domains
      • Is the foundation of Church’s λ-calculus, which is equivalent in power to Turing machines and is the direct precursor of fun-notation

      Key limitations

      • Functions cannot be tested for equality in OCaml. Two separately compiled functions that compute identical results are not considered equal; the comparison would reduce to comparing machine addresses, which is a low-level detail with no place in a principled language.
      • Functional arguments and results must have consistent types enforced by the type checker at compile time.
    • Anonymous Functions

      fun-notation

      The fun-notation expresses a non-recursive function value without giving the function a name:

      fun n -> n * 2
      (* : int -> int = <fun> *)

      This is the function f such that f(n) = n × 2. It can be applied immediately:

      (fun n -> n * 2) 17
      (* : int = 34 *)

      The expression fun n -> n * 2 has the same value as a named declaration:

      let double n = n * 2
      (* val double : int -> int = <fun> *)

      These two forms are equivalent. The fun form is used when the function is needed only once or is being passed as an argument to another function.

      Pattern-matching anonymous functions

      There are three equivalent ways to define an is_zero function:

      FormExpression
      fun with matchfun x -> match x with 0 -> true | _ -> false
      function keywordfunction 0 -> true | _ -> false
      Named with funlet is_zero = fun x -> match x with 0 -> true | _ -> false
      Named with functionlet is_zero = function 0 -> true | _ -> false

      The function keyword is shorthand for fun x -> match x with .... It introduces an anonymous argument that is immediately pattern-matched against the clauses that follow. This is convenient for functions defined by pattern-matching on a single argument.

      When to use each form

      • Use fun x -> E when you need a simple unnamed function taking one argument.
      • Use function P1 -> E1 | P2 -> E2 when the anonymous function pattern-matches on its single argument.
      • Use let f x = E when you want to give the function a name for reuse.
      • Use let rec when the function needs to call itself recursively (anonymous functions via fun cannot be recursive).
    • Currying

      What is currying?

      Currying is the technique of expressing a function taking multiple arguments as nested functions, each taking a single argument. A curried function returns another function as its result.

      Consider the string concatenation operator (^):

      (^)
      (* : string -> string -> string = <fun> *)

      The type string -> string -> string means “a function that takes a string and returns a function from string to string”. Compare with an uncurried version using pairs:

      CurriedUncurried
      string -> string -> stringstring * string -> string
      Takes one string, returns a functionTakes a pair of strings, returns a string
      Allows partial applicationMust supply both arguments together

      Defining curried functions

      A curried function uses nested fun-notation:

      let prefix = fun a -> fun b -> a ^ b
      (* val prefix : string -> string -> string = <fun> *)

      Applying this function to one argument yields another function:

      let promote = prefix "Senior "
      (* val promote : string -> string = <fun> *)
      
      promote "Professor"
      (* : string = "Senior Professor" *)

      Currying and partial application

      Promotion via partial application

      Supplying the first argument of a curried function is called partial application. The resulting function has the first argument “fixed” and awaits the remaining arguments. This is sometimes called promotion: the function prefix applied to "Senior " promotes, yielding promote.

      Mathematical intuition: the function x^y with y = 2 gives squaring; with y = 3 gives cubing; with y = 1 gives the identity function. Each is obtained by fixing the second argument.

      Nesting of fun

      The curried form nests fun expressions:

      fun a -> fun b -> E

      is read as: a function that takes a, and returns a function that takes b and computes E. In OCaml, -> associates to the right, so the type string -> string -> string means string -> (string -> string). Application associates to the left, so prefix "Senior " "Professor" means (prefix "Senior ") "Professor".

    • Shorthand and Partial Application

      Curried shorthand syntax

      OCaml provides a concise syntax for curried function definitions. The following two definitions are equivalent:

      let prefix a b = a ^ b
      (* val prefix : string -> string -> string = <fun> *)
      let prefix = fun a -> fun b -> a ^ b
      (* val prefix : string -> string -> string = <fun> *)

      In general: let f x₁ ... xₙ = E is equivalent to:

      fun x₁ -> ... -> fun xₙ -> E

      Application uses the same syntax: f E₁ ... Eₙ means ((f E₁) ...) Eₙ.

      Partial application

      Partial application means supplying fewer arguments than a curried function expects, producing a new function with the remaining parameters.

      let dub = prefix "Sir "
      (* val dub : string -> string = <fun> *)
      
      dub "Hamilton"
      (* : string = "Sir Hamilton" *)

      This is useful when fixing the first argument yields a function that is interesting in its own right.

      Function lists and partial application

      Curried functions interact naturally with lists of functions. Consider:

      List.hd [dub; promote] "Hamilton"
      (* : string = "Sir Hamilton" *)

      Here List.hd extracts dub from the list of functions, and the resulting function is applied to "Hamilton". The type of this expression works because application associates to the left: (List.hd [dub; promote]) "Hamilton".

      The idea of storing code in data structures and executing it later is fundamental to object-oriented programming.

      Curried insertion sort

      Passing an ordering predicate as a curried argument yields a general insertion sort:

      let insort lessequal =
        let rec ins x = function
          | [] -> [x]
          | y::ys -> if lessequal x y then x :: y :: ys
                     else y :: ins x ys
        in
        let rec sort = function
          | [] -> []
          | x::xs -> ins x (sort xs)
        in
        sort
      (* val insort : ('a -> 'a -> bool) -> 'a list -> 'a list = <fun> *)

      insort is a curried function: given an ordering predicate, it returns the sorting function itself. Usage:

      insort (<=) [5; 3; 9; 8]  (* => [3; 5; 8; 9] *)
      insort (>=) [5; 3; 9; 8]  (* => [9; 8; 5; 3] *)
      insort (<=) ["bitten"; "on"; "a"; "bee"]  (* => alphabetical sort *)

      The syntax (<=) turns the infix operator into a prefix function that can be passed as an argument. Passing (>=) yields a decreasing sort: this is valid because if ≤ is a partial ordering, then so is ≥.

    • Map and Matrix Operations

      The map functional

      map is the “apply to all” functional. It applies a function to every element of a list, returning a list of results:

      let rec map f = function
        | [] -> []
        | x::xs -> (f x) :: map f xs
      (* val map : ('a -> 'b) -> 'a list -> 'b list = <fun> *)

      The type ('a -> 'b) -> 'a list -> 'b list shows that map transforms a function on elements into a function on lists.

      Examples

      map (fun s -> s ^ "ppy") ["Hi"; "Ho"]
      (* => ["Hippy"; "Hoppy"] *)
      
      map (map double) [[1]; [2; 3]]
      (* => [[2]; [4; 6]] *)

      Without map, the first example would require writing a dedicated recursive function. map captures the recursion pattern once and for all.

      Matrix transpose

      A matrix can be represented as a list of rows, where each row is a list of elements:

      [[a; b; c]; [d; e; f]]
      (* represents:  a b c
                       d e f   *)

      The transpose function uses map with hd and tl:

      let rec transp = function
        | []::_ -> []
        | rows -> (map List.hd rows) :: (transp (map List.tl rows))
      (* val transp : 'a list list -> 'a list list = <fun> *)
      ExpressionResult
      map List.hd rowsFirst column: [a; d]
      map List.tl rowsRemaining columns: [[b; c]; [e; f]]

      The first column becomes the first row of the result. Recursion transposes the remaining columns. This is a concise definition that would otherwise require separate helper function declarations.

      Matrix multiplication

      The dot product of two vectors is computed by a curried function:

      let rec dotprod xs ys =
        match xs, ys with
        | [], [] -> 0.0
        | x::xs, y::ys -> (x *. y) +. (dotprod xs ys)
      (* val dotprod : float list -> float list -> float = <fun> *)

      Because dotprod is curried, it can be partially applied to a single row. The matrix product then uses map:

      let rec matprod arows brows =
        let cols = transp brows in
        map (fun row -> map (dotprod row) cols) arows
      (* val matprod : float list list -> float list list -> float list list = <fun> *)

      The inner map (dotprod row) cols computes the dot product of one row of A with every column of B. The outer map repeats this for every row of A. This is a compact replacement for the traditional triple-nested loop, avoiding subscript errors entirely.

    • Predicate Functionals

      Predicates and functionals

      A predicate is a boolean-valued function (type 'a -> bool). Three key functionals transform predicates:

      exists: stop on first true

      let rec exists p = function
        | [] -> false
        | x::xs -> (p x) || (exists p xs)
      (* val exists : ('a -> bool) -> 'a list -> bool = <fun> *)

      Returns true if at least one element satisfies p. The lazy || ensures short-circuit evaluation: as soon as a match is found, the rest of the list is not examined. Without this laziness, exists would always traverse the entire list.

      all: stop on first false

      let rec all p = function
        | [] -> true
        | x::xs -> (p x) && all p xs
      (* val all : ('a -> bool) -> 'a list -> bool = <fun> *)

      Returns true if every element satisfies p. The lazy && stops at the first counterexample.

      filter: return matching elements

      let rec filter p = function
        | [] -> []
        | x::xs -> if p x then x :: filter p xs
                  else filter p xs
      (* val filter : ('a -> bool) -> 'a list -> 'a list = <fun> *)

      Returns the list of all elements satisfying the predicate.

      Applications

      Membership

      let member y xs = exists (fun x -> x = y) xs
      (* val member : 'a -> 'a list -> bool = <fun> *)

      member tests whether y appears in xs using exists with an anonymous equality predicate.

      Intersection

      let inter xs ys = filter (fun x -> member x ys) xs
      (* val inter : 'a list -> 'a list -> 'a list = <fun> *)

      inter filters xs, keeping only those elements that also appear in ys.

      Disjointness

      let disjoint xs ys = all (fun x -> all (fun y -> x <> y) ys) xs
      (* val disjoint : 'a list -> 'a list -> bool = <fun> *)

      Returns true if the two lists share no common elements. The inner all checks that x is different from every element of ys; the outer all checks this for every x in xs.

      Historical context

      Alonzo Church’s λ-calculus gave a simple syntax (λ-notation) for expressing functions. It is the direct precursor of OCaml’s fun-notation. Church’s thesis states that λ-definable functions are precisely the effectively computable ones, equivalent in power to Turing machines.

      Peter Landin (Queen Mary College, London) sketched the main features of functional programming languages in 1966. McCarthy’s Lisp, while historically important, initially interpreted variable binding incorrectly - an error that persisted for about twenty years. The λ-calculus has had a profound influence on the design of modern functional languages.

  • Sequences and Lazy Lists

    The producer-filter-consumer pipeline model, lazy lists (sequences) defined as the datatype 'a seq = Nil | Cons of 'a * (unit -> 'a seq), delaying evaluation with fun () -> E, the infinite sequence from k generating k, k+1, k+2, …, consuming sequences with get that forces evaluation one element at a time, joining sequences with appendq (biased) vs interleave (fair), functionals over lazy lists (filterq, iterates), and numerical computation on infinite sequences with the Newton-Raphson method for square roots (within, next, root)

    • Pipelines and Lazy Evaluation

      Two kinds of programs

      SequentialReactive
      Accepts problem, processes, terminatesInteracts continuously with environment
      E.g. numerical simulationE.g. aircraft control software
      Most OCaml functions in this courseRequires concurrency (beyond Part IA scope)

      Producer → Filter → Consumer pipeline

      We can model a simple pipeline without full concurrency. The model is:

      Producer → Filter → ... → Filter → Consumer
      • The Producer outputs items as a stream.
      • Filters transform the stream (perhaps consuming several items to produce one output).
      • The Consumer takes elements as needed.

      Pipeline flow

      The Consumer drives the pipeline: nothing is computed except in response to demand for the next datum. Execution of filter stages is interleaved as needed. The programmer sets up data dependencies but has no clear idea of what happens when - creating an illusion of concurrency.

      Unix analogy

      Unix pipes (|) link processes together similarly. Data flows from one process to the next, with the consumer pulling data through the chain. In OCaml, lazy lists provide the same effect within a single program.

      Why this matters

      • Avoids computing values that are never consumed
      • Handles potentially infinite data sources naturally
      • Separates generation, transformation, and consumption concerns cleanly
      • Particularly useful when a problem has many solutions but only a few are needed (e.g. making change, search problems)
    • Lazy Lists in OCaml

      The unit type

      The primitive type unit has exactly one value, written (). It may be regarded as a 0-tuple. Its uses include:

      • Placeholder in data structures (e.g. a unit-valued dictionary represents a set)
      • Argument to delay evaluation: fun () -> E
      • Result of procedures that perform side effects

      The lazy list type

      type 'a seq =
        | Nil
        | Cons of 'a * (unit -> 'a seq)

      A sequence is either empty (Nil) or a Cons cell containing a head value and a tail function of type unit -> 'a seq. The tail is not evaluated until the function is called - this is delayed evaluation, which is weaker than full lazy evaluation but sufficient for our purposes.

      Lazy list structure

      Key operations

      let head (Cons (x, _)) = x
      
      let tail (Cons (_, xf)) = xf ()
      • head returns the first element immediately.
      • tail forces evaluation by calling the tail function xf() with the unit argument (). The () serves as a placeholder - we need some argument to trigger evaluation, but no actual information is required.

      Delaying evaluation

      The expression fun () -> E delays evaluation of E: E is not computed until the function is called, even though the only possible argument is (). This is the key mechanism for implementing laziness in a strict language like OCaml.

      The pattern

      Every force must be enclosed within a delay for a function to remain lazy:

      (* Correct - lazy *)
      Cons (x, fun () -> ... xf () ...)
      
      (* Risky - may force the whole sequence *)
      ... xf () ...

      A force (xf()) not protected by a delay runs the risk of evaluating the entire sequence, defeating the purpose of laziness.

      OCaml vs Haskell

      Haskell uses lazy evaluation everywhere - even if-then-else can be a function and all lists are lazy. OCaml provides delayed evaluation through the unit -> 'a thunk pattern, which is a good compromise between performance and expressiveness. The result of forcing is not memoised in OCaml (unlike Haskell), so repeated forcing recomputes the value.

    • Infinite Sequences

      Generating infinite sequences: from

      let rec from k = Cons (k, fun () -> from (k + 1))
      (* val from : int -> int seq = <fun> *)

      from k produces the infinite sequence k, k+1, k+2, … . Execution terminates because the recursive call from (k+1) is enclosed within fun () -> .... Without this delay, the recursion would be unbounded and the function would never return.

      Interactive exploration

      let it = from 1
      (* val it : int seq = Cons (1, <fun>) *)
      
      let it = tail it
      (* val it : int seq = Cons (2, <fun>) *)
      
      let it = tail it
      (* val it : int seq = Cons (3, <fun>) *)

      OCaml displays the tail of a sequence as <fun>. Each call to tail generates the next element.

      Consuming a sequence: get

      let rec get n s =
        match n, s with
        | 0, _            -> []
        | n, Nil          -> []
        | n, Cons (x, xf) -> x :: get (n - 1) (xf ())
      (* val get : int -> 'a seq -> 'a list = <fun> *)

      get n s extracts the first n elements as an ordinary list. The call xf() forces evaluation of the next element. If n is negative, it takes all elements (terminating only for finite sequences).

      Evaluation trace

      Consider get(2, from 6):

      get(2, from 6)
      => get(2, Cons(6, fun () -> from (6+1)))
      => 6 :: get(1, from (6+1))
      => 6 :: get(1, Cons(7, fun () -> from (7+1)))
      => 6 :: 7 :: get(0, Cons(8, fun () -> from (8+1)))
      => 6 :: 7 :: []
      => [6; 7]

      Note: three elements are computed (6, 7, and 8), even though only two were requested. Our implementation is slightly too eager - the tail of the last Cons is constructed before get checks that n = 0. A more complex type declaration could avoid this. In a truly lazy language like Haskell, only the demanded elements would be computed.

    • Joining and Interleaving Sequences

      appendq: lazy concatenation

      let rec appendq xq yq =
        match xq with
        | Nil -> yq
        | Cons (x, xf) -> Cons (x, fun () -> appendq (xf ()) yq)
      (* val appendq : 'a seq -> 'a seq -> 'a seq = <fun> *)

      This is the lazy analogue of list append (@). It copies elements from xq until exhausted, then switches to yq.

      Critical limitation

      If xq is infinite, appendq never reaches yq. The second argument is silently lost. Concatenation of infinite sequences is not terribly useful for this reason.

      interleave: fair combination

      let rec interleave xq yq =
        match xq with
        | Nil -> yq
        | Cons (x, xf) -> Cons (x, fun () -> interleave yq (xf ()))
      (* val interleave : 'a seq -> 'a seq -> 'a seq = <fun> *)

      interleave exchanges the arguments in the recursive call. This means:

      • First element comes from xq
      • Second element comes from yq (the old second argument, now first)
      • Third element comes from the rest of xq
      • And so on, alternating

      This is fair: no elements from either sequence are lost, even if both are infinite. Interleaving is the right way to combine two potentially infinite information sources into one.

      The key pattern: delay around force

      In both appendq and interleave, observe that each xf() (a force) is enclosed within fun () -> ... (a delay):

      Cons (x, fun () -> appendq (xf ()) yq)

      This is the standard recipe for maintaining laziness. A force not enclosed in a delay (as in the get function) will compute the next element immediately rather than on demand.

      Comparison

      OperationFair?Use case
      appendqNo - loses second arg if first is infiniteFinite prefixes
      interleaveYes - alternates between bothMerging infinite streams
    • Functionals for Lazy Lists

      filterq: lazy filtering

      let rec filterq p = function
        | Nil -> Nil
        | Cons (x, xf) ->
            if p x then
              Cons (x, fun () -> filterq p (xf ()))
            else
              filterq p (xf ())
      (* val filterq : ('a -> bool) -> 'a seq -> 'a seq = <fun> *)

      filterq demands elements from the sequence until it finds one satisfying p. It contains a force (xf()) not protected by a delay in the else branch: if no element satisfies the predicate within a finite prefix, filterq will evaluate the rest before producing any output.

      Behaviour with infinite sequences

      If the input sequence is infinite and contains no element satisfying p, then filterq runs forever without producing any output. This is a natural consequence: the function cannot know that no matching element exists without examining every element, of which there are infinitely many.

      iterates: generalised from

      let rec iterates f x =
        Cons (x, fun () -> iterates f (f x))
      (* val iterates : ('a -> 'a) -> 'a -> 'a seq = <fun> *)

      iterates f x generates the infinite sequence:

      x, f(x), f(f(x)), f(f(f(x))), …

      This generalises from: from k is equivalent to iterates (fun n -> n + 1) k.

      Exercise: map analogue for sequences

      A lazy map functional would apply a function to every element of a sequence on demand. The key is to delay the recursive call:

      let rec mapq f = function
        | Nil -> Nil
        | Cons (x, xf) -> Cons (f x, fun () -> mapq f (xf ()))

      The fun () -> around the recursive call ensures that the tail is computed only when forced by the consumer.

    • Newton-Raphson Square Roots

      The Newton-Raphson iteration

      The Newton-Raphson method computes square roots. Given aa, the iteration is:

      xnext=ax+x2x_{\text{next}} = \frac{\frac{a}{x} + x}{2}

      In OCaml:

      let next a x = (a /. x +. x) /. 2.0
      (* val next : float -> float -> float = <fun> *)

      Convergence on infinite sequences

      iterates (next a) x₀ generates the infinite series of approximations. For 2\sqrt{2} with x0=1.0x_0 = 1.0:

      IterationApproximation
      x0x_01.0
      x1x_11.5
      x2x_21.41667…
      x3x_31.4142157…
      x4x_41.414213562…

      The last value is accurate to 10 significant digits. Convergence is quadratic: the number of correct digits roughly doubles each iteration.

      within: stopping criterion

      let rec within eps = function
        | Cons (x, xf) ->
            match xf () with
            | Cons (y, yf) ->
                if abs_float (x -. y) <= eps then y
                else within eps (Cons (y, yf))
      (* val within : float -> float seq -> float = <fun> *)

      within eps searches the lazy list for two consecutive approximations whose absolute difference is at most eps. When found, it returns the second (more accurate) value.

      root: putting it together

      let root a = within 1e-6 (iterates (next a) 1.0)
      (* val root : float -> float = <fun> *)
      
      root 2.0
      (* : float = 1.41421356237309492 *)

      This composes three small, interchangeable components:

      1. next a - one iteration step
      2. iterates (next a) 1.0 - infinite series of approximations
      3. within 1e-6 - convergence test

      The initial approximation of 1.0 is deliberately poor to demonstrate the method’s robustness.

      Richardson extrapolation

      This compositional approach to numerical computation has received attention in research literature. A recurring example is Richardson extrapolation, which improves the accuracy of numerical integration by combining results at different step sizes. The principle is the same: build numerical methods from small, combinable parts operating on sequences.

  • Queues and Search Strategies

    Breadth-first vs depth-first tree traversal and their trade-offs, naive breadth-first traversal using append (wasteful), the queue abstract data type (qempty, qnull, qhd, deq, enq) with a FIFO discipline, an efficient functional queue using a pair of lists (front in order, rear reversed) with O(1) amortised operations, breadth-first traversal using queues (200x faster than append-based), iterative deepening as a space-efficient alternative to breadth-first search (O(b^d) time, O(d) space), stacks as the LIFO analogue of queues, and how the choice of data structure (stack, queue, priority queue) determines the search strategy (DFS, BFS, best-first)

    • BFS vs DFS

      Two search strategies

      When traversing a tree to find a solution node, two fundamental strategies exist:

      PropertyDepth-First Search (DFS)Breadth-First Search (BFS)
      OrderExplore one subtree fully before siblingsExplore all nodes at level k before level k+1
      Data structureStack (LIFO)Queue (FIFO)
      Space complexityO(d) where d is depthO(bd) where b is branching factor
      CompletenessIncomplete for infinite/deep treesComplete - finds solution if one exists
      OptimalityMay find deep solution before shallow oneFinds nearest (shortest path) solution first

      BFS vs DFS

      Depth-first behaviour

      DFS goes deep before going wide. At each node, the left subtree is explored completely before the right subtree. Preorder, inorder, and postorder traversals are all depth-first.

      Problem: If the left subtree is infinite, DFS never reaches the right subtree. A solution sitting at the top of the right subtree will never be found.

      Breadth-first behaviour

      BFS visits nodes level by level horizontally. When visiting a node, it does not traverse its children until it has visited all other nodes at the current depth.

      Implementation idea: Maintain a list of pending trees to visit. Initially contains just the root. Each step removes a tree from the front, and if it is a branch, adds its children to the back.

      Problem: The space requirement grows exponentially with depth. At depth d, up to bd nodes may be stored simultaneously.

      When to use each

      • DFS when the tree is finite, solutions are plentiful, and memory is constrained.
      • BFS when the shortest path matters, or when the tree may be infinite/unbalanced.
      • Neither is ideal for large/infinite search spaces - iterative deepening (see later) combines their strengths.
    • Naive Breadth-First Search

      Implementation with lists and append

      let rec nbreadth = function
        | [] -> []
        | Lf :: ts -> nbreadth ts
        | Br (v, t, u) :: ts ->
            v :: nbreadth (ts @ [t; u])
      (* val nbreadth : 'a tree list -> 'a list = <fun> *)

      nbreadth maintains a list of pending trees. It removes the first tree, and if it is a branch, appends its children to the end of the list.

      Why this is inefficient

      The expression ts @ [t; u] is the problem. @ copies its entire first argument to append two elements. At depth d, the list ts contains all remaining trees at the current depth plus subtrees already queued for the next depth - potentially hundreds or thousands of elements. Each append copies them all.

      Performance evidence

      On a full binary tree of depth 12 (containing 4095 labels):

      ImplementationTime
      nbreadth (naive, with append)30 seconds
      breadth (with queues)0.15 seconds
      Speedup200×

      For larger trees, the speedup would be even more dramatic. The naive version is O(n2) in the size of the pending list, while the queue-based version is O(n).

      Why this matters

      This is a classic example of how a poor choice of data structure (using append to simulate a queue) can catastrophically degrade performance. The algorithm is correct, but the implementation is wasteful. The fix is to use a proper queue data structure that supports efficient addition to the tail, which we develop next.

    • Functional Queues

      Queue ADT

      A queue is a sequence with First-In-First-Out (FIFO) discipline. The item next to be removed is the one that has been in the queue the longest.

      OperationDescription
      qemptyThe empty queue
      qnull qTest whether q is empty
      qhd qReturn the element at the head
      deq qDiscard the element at the head
      enq q xAdd x to the tail

      The two-list representation

      Represent the queue x₁x₂…xₘyₙ…y₁ by a pair of lists:

      ([x₁; x₂; ...; xₘ], [y₁; y₂; ...; yₙ])
      • The front list is stored in order: the head of this list is the head of the queue.
      • The rear list is stored in reverse order: the head of this list is the tail of the queue.

      Functional queue

      Operations

      type 'a queue = Q of 'a list * 'a list

      Enqueue adds to the rear list using :: (O(1)):

      let enq (Q (hds, tls)) x = norm (Q (hds, x :: tls))

      Dequeue removes from the front list using pattern-matching (O(1) normally):

      let deq = function
        | Q (x :: hds, tls) -> norm (Q (hds, tls))
        | _ -> raise Empty

      Head reads from the front list:

      let qhd = function
        | Q (x :: _, _) -> x
        | _ -> raise Empty

      Normalisation

      The function norm ensures the front list is never empty unless the queue is empty:

      let norm = function
        | Q ([], tls) -> Q (List.rev tls, [])
        | q -> q

      When the front list becomes empty, norm reverses the rear list, making it the new front list. This reversal takes O(n) time for a rear list of length n, but as we shall see, the amortised cost per operation is O(1).

    • Amortised Analysis and BFS with Queues

      Amortised O(1) analysis

      Consider an execution starting from an empty queue with n enq operations and n deq operations, in any order.

      • Each enq performs one ::: adds element to rear list. Total: n conses.
      • Each element is transferred from rear to front exactly once during a norm reversal. Each transfer costs one ::. Total: n conses.
      • Total cost: 2_n_ cons operations for 2_n_ operations.
      • Average: 2 cons per operation = O(1) amortised.

      This is amortised analysis: the cost per operation averaged over the lifetime of a complete execution. Even for the worst possible execution sequence, the average is constant.

      The catch

      The conses are not distributed evenly. A single deq that triggers a reversal could take O(n) time. This makes the two-list queue unsuitable for real-time systems where predictable deadlines matter. But for most purposes, the O(1) amortised bound is excellent.

      BFS using queues

      let rec breadth q =
        if qnull q then []
        else
          match qhd q with
          | Lf -> breadth (deq q)
          | Br (v, t, u) -> v :: breadth (enq (enq (deq q) t) u)
      (* val breadth : 'a tree queue -> 'a list = <fun> *)

      This implements the same algorithm as nbreadth but uses the two-list queue. Each iteration:

      1. Dequeues the first tree.
      2. If it is a leaf, continues with the rest.
      3. If it is a branch with label v and children t, u: outputs v, then enqueues both children.

      Performance comparison

      On a full binary tree of depth 12 (4095 labels):

      ImplementationData structureTimeRatio
      nbreadthList + append30.0 s-
      breadthTwo-list queue0.15 s200× faster

      For larger trees, the speedup grows. Choosing the right data structure pays handsomely.

    • Iterative Deepening Search

      The problem with BFS and DFS

      StrategySpaceCompleteness
      DFSO(d)Incomplete for infinite trees
      BFSO(bd)Complete, finds nearest solution

      For a branching factor b = 2 at depth d = 20, BFS stores about 220 ≈ 1,000,000 nodes. DFS stores about 20. But DFS might never find a solution in an infinite tree.

      Iterative deepening

      Iterative deepening performs repeated depth-first searches with increasing depth bounds:

      1. Search to depth 1. If no solution, discard results.
      2. Search to depth 2. If no solution, discard results.
      3. Search to depth 3. And so on…

      Iterative deepening

      Analysis

      PropertyValue
      SpaceO(d) - same as DFS
      CompletenessYes - same as BFS
      OptimalityFinds nearest solution first
      Time overheadb/(b − 1) relative to BFS (if b > 1)

      The time overhead is a constant factor. For binary trees (b = 2), the overhead is 2/(2−1) = 2: iterative deepening does at most twice the work of BFS. In exchange, it reduces space from exponential (O(bd)) to linear (O(d)).

      Why discarding is acceptable

      There are bd+1 nodes at level d+1. If b ≥ 2, this number exceeds the total number of nodes at all previous levels combined:

      bd+1 > (bd+1 − 1)/(b − 1) (for b ≥ 2)

      The majority of the work is always at the deepest level. Recomputation of shallower levels is therefore cheap relative to exploring the new depth. Korf showed the total time is only b/(b−1) times that of BFS.

      Limitation

      If b ≈ 1 (a nearly linear tree), iterative deepening loses its advantage. The number of nodes grows slowly, so the recomputation overhead becomes significant.

    • Stacks and Search Survey

      Stacks

      A stack is a sequence with Last-In-First-Out (LIFO) discipline. The item next to be removed is the one most recently added.

      OperationDescription
      emptyThe empty stack
      null sTest whether s is empty
      top sReturn the element at the top
      pop sDiscard the top element
      push s xAdd x to the top

      Lists as stacks

      OCaml lists naturally implement stacks:

      Stack operationList equivalent
      push s xx :: s
      top sList.hd s
      pop sList.tl s

      Both :: and hd operate on the head in O(1) time.

      Imperative stacks

      In conventional languages, a stack is typically implemented with an array and a pointer (stack pointer). The pointer tracks how many elements are in use. Most language processors use an internal stack to manage recursive function calls.

      Search strategy survey

      The choice of data structure determines the search strategy:

      Search strategyData structureDiscipline
      Depth-firstStackLIFO
      Breadth-firstQueueFIFO
      Best-firstPriority queueOrdered by heuristic
      Iterative deepeningStack (DFS repeated)LIFO with depth bound

      Store pending nodes in a priority queue ordered by a heuristic ranking function. The function estimates the distance from each node to a solution. If the estimate is good, the solution is located swiftly.

      Priority queues can be implemented with sorted lists (slow), binary search trees (O(log n) average), or more sophisticated structures like heaps.

      Summary

      • DFS: stack-based, space-efficient, incomplete for infinite trees.
      • BFS: queue-based, finds shortest path, space-hungry.
      • Iterative deepening: DFS repeated with increasing bounds, combines space efficiency of DFS with completeness of BFS.
      • Best-first: priority queue, uses domain knowledge for efficiency.
  • Elements of Procedural Programming

    Procedural programming and state mutation contrasted with functional purity, OCaml references (ref, !, :=) as mutable cells, trying out references in the REPL and how let-bindings are immutable while reference contents are mutable, commands as expressions with effects and the sequence operator (C1 ; C2), iteration with the while loop and loop invariants, private persistent references (the bank account example where makeAccount returns a withdraw function with sole access to a private balance cell), OCaml arrays (allocation, init, get, set) with bounds checking, and comparison of OCaml's explicit reference model with conventional languages' implicit dereferencing

    • Procedural Programming Overview

      Functional vs procedural

      Functional programmingProcedural programming
      Expressions compute valuesCommands transform state
      No side effectsState mutation is the norm
      Recursion for repetitionLoops (while, for) for repetition
      Immutable dataMutable variables, arrays, references

      What procedural programs do

      Procedural programs repeatedly transform a program state by executing commands or statements. A state change might be:

      • Updating a variable or array (local to the machine)
      • Sending data to the outside world (output)
      • Reading data from the environment (input, which also changes state by consuming data)

      Building blocks

      ConstructPurpose
      AssignmentUpdate variables: x := E
      Input/OutputCommunicate with environment
      if / matchConditional execution
      whileRepetition
      Procedures (functions)Package commands as reusable abstractions

      The need for subroutines (procedures) was evident from the earliest days of programming. They represent one of the first examples of abstraction in programming languages.

      OCaml’s blend

      OCaml makes no distinction between commands and expressions. Everything is an expression that returns a value. However:

      • Commands typically return () of type unit - the trivial value carrying no information.
      • The semicolon ; sequences expressions, discarding intermediate results.
      • OCaml programmers often use a functional style for internal computation and imperative features for I/O.

      This dual nature means OCaml can teach procedural concepts while keeping the safety guarantees of a strongly typed functional language.

    • References

      The three reference primitives

      SyntaxTypeEffect
      ref E'a -> 'a refCreate a new reference cell with initial contents = value of E
      !P'a ref -> 'aReturn current contents of reference P (dereference)
      P := E'a ref -> 'a -> unitUpdate contents of P to value of E

      Using references

      let p = ref 5
      (* val p : int ref = {contents = 5} *)
      
      p := !p + 1
      (* : unit = ()  -- p now holds 6 *)
      • ref 5 allocates a new memory location, initially holding 5.
      • !p reads the current contents (dereferencing).
      • p := ... updates the contents, returning ().

      References and mutation

      Important distinctions

      • let bindings are immutable. p will always refer to the same reference cell unless shadowed by a new declaration.
      • Only the contents of the cell is mutable.
      • The type int ref is distinct from int. You cannot assign to a plain int variable.
      • OCaml displays reference values as {contents = v}, which is readable but does not reveal whether two references holding the same value are the same cell.

      Lists of references

      let ps = [ref 77; p]
      (* val ps : int ref list = [{contents = 77}; {contents = 6}] *)
      
      List.hd ps := 3
      (* : unit = () *)
      
      ps
      (* : int ref list = [{contents = 3}; {contents = 6}] *)

      The assignment List.hd ps := 3 updates the contents of the first reference in the list. It does not modify the list ps itself - only the cell that the first element points to.

      Explicit vs implicit dereference

      OCaml requires explicit dereferencing with !. Most conventional languages make dereferencing implicit: on the left of =, a variable denotes a location; on the right, it denotes the contents. While implicit dereferencing makes programs shorter, it is logically messy. OCaml’s explicitness makes it ideal for teaching.

      OCamlConventional equivalent
      let p = ref 5int p = 5
      p := !p + 1p = p + 1
      !pp (in an expression)
    • Commands and Sequencing

      Commands as expressions

      A command is informally an expression that has an effect on the state. In OCaml, all expressions return a value, but commands typically return () of type unit - a value carrying no information.

      The sequence operator

      The construct C₁; C₂; ...; Cₙ evaluates the expressions in order and returns the value of Cₙ:

      let _ =
        print_endline "Hello"; print_endline "World"; 42
      (* prints "Hello" then "World", returns 42 *)
      • C₁ through Cₙ₋₁ are evaluated for their side effects; their return values are discarded.
      • Only Cₙ’s value is returned.
      • The semicolons are the sequence operator.

      Types flow through correctly: if C₁ : unit and C₂ : int, then C₁; C₂ : int.

      Conditional commands

      if-then-else works with commands naturally:

      let _ =
        if !balance >= amt then
          (balance := !balance - amt; true)
        else
          false

      Both branches must return the same type. The parentheses group sequenced commands into a single expression.

      Pattern matching with commands

      match also works with effects:

      let _ =
        match tlopt !lp with
        | None -> fin := true
        | Some xs -> lp := xs; np := !np + 1

      Each match arm can be a sequence of commands. OCaml functions play the role of procedures - they can contain commands and be called for their effects.

      Other languages

      Languages that combine functional and imperative paradigms include Lisp (and its dialect Scheme), Scala, and older systems programming languages like BLISS. OCaml’s approach is distinctive in that functional purity is the default and imperative features must be explicitly opted into via ref and :=.

    • While Loops

      The while construct

      while B do
        C
      done

      The boolean expression B is evaluated. If true, command C is executed and the loop repeats. If false, the while terminates (possibly without executing C even once). The construct always returns ().

      Example: imperative list length

      let tlopt = function
        | [] -> None
        | _ :: xs -> Some xs
      
      let length xs =
        let lp = ref xs in
        let np = ref 0 in
        let fin = ref false in
        while not !fin do
          match tlopt !lp with
          | None -> fin := true
          | Some xs ->
              lp := xs;
              np := 1 + !np
        done;
        !np
      (* val length : 'a list -> int = <fun> *)

      This computes list length imperatively:

      • lp holds the remaining unexamined list.
      • np accumulates the count.
      • fin signals when the list is exhausted.
      • tlopt safely returns None for the empty list (avoiding an exception from tl).

      The loop body either sets fin to terminate, or advances lp and increments np.

      Comparison with tail recursion

      While loopTail recursion
      Uses mutable referencesUses accumulator arguments
      Returns ()Returns the accumulated value
      Loop invariant describes stateInduction on recursive calls
      More familiar to imperative programmersMore natural in functional style

      Both achieve O(n) time and O(1) space. The choice is stylistic. OCaml compilers optimise tail recursion well, so the recursive version is typically preferred in idiomatic OCaml.

      Loop invariants

      A loop invariant is a property that holds:

      1. Before the first iteration.
      2. After each iteration.
      3. When the loop terminates.

      For length: the invariant is that !np is the number of elements examined so far, and !lp holds the unexamined suffix of the original list. When !lp is empty (fin = true), !np equals the total length.

    • Private References and Bank Accounts

      Encapsulation via closures

      OCaml references are more flexible than those in conventional languages. By capturing a reference in a closure, we can create private, persistent state - analogous to objects in OOP.

      makeAccount

      exception TooMuch of int
      
      let makeAccount initBalance =
        let balance = ref initBalance in
        let withdraw amt =
          if amt > !balance then
            raise (TooMuch (amt - !balance))
          else begin
            balance := !balance - amt;
            !balance
          end
        in
        withdraw
      (* val makeAccount : int -> int -> int = <fun> *)

      Key points:

      • balance is a private reference created inside makeAccount.
      • withdraw is the only function with access to balance - it is captured in a closure.
      • The balance cannot be modified except by calling withdraw.
      • An exception TooMuch prevents overdrafts; the amount by which the withdrawal exceeds the balance is carried in the exception.
      • Deposits are withdrawals of a negative amount.

      The begin/end block

      The syntax begin ... end is equivalent to ( ... ) - it groups expressions into a single expression. In withdraw, it sequences the assignment and the return of the new balance:

      begin
        balance := !balance - amt;
        !balance
      end

      Two independent accounts

      let student = makeAccount 500
      let director = makeAccount 4000000
      
      student 5       (* => 495 *)
      director 150000 (* => 3850000 *)
      student 500     (* raises TooMuch 5 *)

      Each call to makeAccount creates a fresh reference cell. The two accounts are completely independent. Neither account holder can access the other’s balance - the encapsulation is enforced by the language, not by convention.

      Analogy to OOP

      ConceptIn OOP (e.g. Java)In OCaml
      Private stateInstance variablesCaptured ref cells
      MethodsObject methodsReturned closure functions
      ConstructorClass constructormakeAccount function
      Encapsulationprivate keywordLexical scoping

      If the returned function is discarded, the reference becomes unreachable and the garbage collector reclaims it - analogous to closing a dormant bank account.

      Generalisation

      makeAccount could return multiple functions (e.g. withdraw, deposit, statement) that jointly manage shared private references. In OCaml, these would typically be packaged using records, though they are not covered in this course.

    • Arrays and Mutable Lists

      OCaml arrays

      OCaml arrays are like references that hold multiple elements instead of one. An n-element array’s elements are designated by indices 0 to n−1.

      Core array operations

      OperationTypeDescription
      Array.make n xint -> 'a -> 'a arrayCreate array of size n, all cells = x
      Array.init n fint -> (int -> 'a) -> 'a arrayCreate array with cell i = f(i)
      Array.get a i'a array -> int -> 'aReturn a[i]
      Array.set a i x'a array -> int -> 'a -> unitSet a[i] := x

      Examples

      let aa = Array.init 5 (fun i -> i * 10)
      (* [|0; 10; 20; 30; 40|] *)
      
      Array.get aa 3      (* => 30 *)
      Array.set aa 3 42   (* aa[3] := 42, returns () *)

      Bounds checking

      OCaml arrays are bounds-checked. Accessing Array.get ar 20 on a 20-element array raises Invalid_argument "index out of bounds". This is much safer than C, where an array is merely a starting address with no size information, making buffer overrun attacks possible.

      Two-dimensional arrays

      An n × n identity matrix can be created as an array of arrays:

      Higher dimensions are represented similarly by nesting. OCaml arrays cannot grow: if you outgrow an array, you must allocate a new one (typically double the size) and copy the data.

      Mutable lists

      type 'a mlist =
        | Nil
        | Cons of 'a * 'a mlist ref

      A mutable list is like an ordinary list, but the tail pointer is a reference rather than a direct value. This allows in-place modification: you can redirect the tail of a cons cell to point to a different list.

      This is analogous to linked data structures taught in algorithms courses, where nodes contain pointers to other nodes. The syntax differs from C/Java, but the principles are the same.

      Comparison of list types

      TypeTailMutation
      'a list (built-in)Direct 'a listImmutable
      'a seq (lazy)unit -> 'a seq (thunk)Not directly; lazily computed
      'a mlist (mutable)'a mlist refIn-place via :=

      A tripos question might ask you to code odds (return alternate elements) for all three list types, or to detect cycles in a mutable list using reference equality (==).

  • Examinable Material and Tripos Revision

    Examinable syllabus checklist and full worked solutions for all available recent past paper questions (2021–2025)

    • Examinable Syllabus Checklist

      What the exam tests

      The Part IA Foundations of Computer Science paper (Paper 1) covers 11 lectures taught in Michaelmas term. The exam typically has 2 questions, each worth 20 marks, testing a mixture of bookwork (definitions, complexity analysis, concepts) and application (writing OCaml functions, reasoning about algorithms, tracing computations). Since 2019 the course uses OCaml syntax; earlier past papers use Standard ML syntax. Students must learn everything in the slide deck unless specifically marked unexaminable.

      Syllabus checklist

      1. Introduction to Programming (Lecture 1)

      • Abstraction levels and interfaces; the abstraction barrier; how it allows one level to change without affecting levels above
      • Data types: representation, operations, valid results; floating-point inaccuracy; integer overflow
      • Goals of programming: expressions (compute values) vs commands (cause effects); programming in-the-small vs in-the-large
      • OCaml basics: let bindings, float vs int, infix operators (*., +., etc.), type inference, type constraints
      • Recursive functions: let rec, the if-then-else conditional; mathematical justification via recurrence equations
      • The npower function: naive recursive exponentiation, O(n) time, O(n) space
      • The power (fast exponentiation) function: divide-and-conquer using squaring, O(log n) time, O(log n) space
      • The even function, mod operator, integer division /
      • Conversion functions: int_of_float, float_of_int
      • Boolean operators: &&, ||, not; relational operators: <, =, <>

      2. Recursion and Efficiency (Lecture 2)

      • Expression evaluation: reducing E_i to E_{i+1} until a value v is reached; functional programming model
      • nsum: naive recursive sum, O(n) time and space; the nesting problem and stack overflow
      • summing: iterative (tail-recursive) sum with accumulator, O(n) time, O(1) space
      • Recursion vs iteration: tail-recursion only efficient if compiler detects it; KISS principle; closed-form n(n+1)/2
      • sillySum: double recursive call, O(2^n) time, O(n) space
      • let-bindings for local computation, avoiding repeated evaluation
      • Big-O notation: formal definition |f(n)| ≤ c|g(n)| for sufficiently large n; ignoring constant factors
      • Simple facts about O notation: constant factors drop out; log base irrelevant; insignificant terms drop out
      • Common complexity classes: O(1) constant, O(log n) logarithmic, O(n) linear, O(n log n) quasi-linear, O(n^2) quadratic, O(n^3) cubic, O(a^n) exponential
      • Cost table: npower/nsum O(n) time/space; summing O(n) time O(1) space; power O(log n); sillySum O(2^n)
      • Simple recurrence relations: T(n+1)=T(n)+1 → O(n), T(n+1)=T(n)+n → O(n^2), T(n)=T(n/2)+1 → O(log n), T(n)=2T(n/2)+n → O(n log n)

      3. Lists (Lecture 3)

      • List syntax: [x1; x2; ...; xn], all elements same type; @ (append) and List.rev
      • The two list primitives: [] (nil) and :: (cons); :: is O(1); lists as linked structures
      • Pattern matching with function and |; exhaustive matching warnings; _ wildcard; type variables 'a, 'b
      • null, hd, tl primitives; partial functions raised Match_failure on []
      • nlength: naive recursive length, O(n) time and space; addlen: iterative length with accumulator, O(n) time, O(1) space
      • append (@): copies first list, O(n) time and space where n is length of first argument; not iterative
      • nrev: naive reverse using append, O(n^2) time (n(n+1)/2 conses), O(n) space
      • rev_app / rev: efficient reverse with accumulator, O(n) time, O(1) space; reduction in strength (replacing append with cons)
      • Strings and characters: char vs string; String.length, ^ concatenation; lexicographic ordering
      • Polymorphism in list functions; id type 'a -> 'a; loop type 'a -> 'b (non-terminating)

      4. More on Lists (Lecture 4)

      • take and drop: divide lists; take copies O(i) time/space; drop skips O(i) time, O(1) space
      • Linear search: O(n); ordered search O(log n); indexed lookup O(1); trade-offs
      • member: polymorphic equality =; limitations on recursive/function types
      • zip and unzip: building and taking apart lists of pairs; wildcard pattern _; pattern matching order
      • Building pairs of results: conspair, revUnzip; iterative construction of multiple results at once
      • Making change (greedy): succeeds for most coin systems, can fail; raises Failure exception when impossible
      • Making change (all solutions): returns int list list; allc local function; base case [[]] for zero amount
      • Making change (faster): double accumulator chg and chgs; eliminates :: and @ operations; stepwise refinement
      • The greedy algorithm may fail for some coin systems (e.g., making 6 with coins 5 and 2)

      5. Sorting (Lecture 5)

      • Lower bound on comparison-based sorting: log(n!) ≈ n log n - 1.44n; n! permutations, each comparison halves the set
      • Insertion sort: ins inserts into sorted list; insort is O(n^2) average case; simple but slow
      • Quicksort: choose pivot, partition into ≤ a and > a, recurse, append; average O(n log n), worst case O(n^2) (already-sorted input)
      • Append-free quicksort (quik): accumulator sorted eliminates append; faster in practice
      • merge: combining two sorted lists, at most m + n - 1 comparisons; not iterative
      • Top-down merge sort: take/drop to split, recursive sort, merge; worst-case O(n log n); safer than quicksort
      • Summary: optimal is O(n log n); insertion sort O(n^2); quicksort average O(n log n) worst O(n^2); merge sort always O(n log n)
      • Match algorithm to application; non-comparison sorts (radix) can achieve O(n) for restricted inputs

      6. Datatypes and Trees (Lecture 6)

      • Enumeration types: type vehicle = Bike | Motorbike | Car | Lorry; constructors as patterns; compiler exhaustiveness checking
      • Constructors with arguments: e.g., Motorbike of int; different constructors distinguish different types; mixed-type lists via datatypes
      • Pattern matching on constructors with arguments: binding values; wildcard _; multiple clauses
      • Exceptions: raise, try...with, exception constructors; exn type; exception trace (backtracking)
      • Option type: None | Some of 'a as alternative to exceptions; 'a option built-in
      • Making change with exceptions: backtracking via exception handler; greedy with undo; can find solutions the greedy algorithm misses
      • Binary trees as recursive datatype: type 'a tree = Lf | Br of 'a * 'a tree * 'a tree
      • Lists as datatypes: type 'a mylist = Nil | Cons of 'a * 'a mylist
      • Tree properties: count (number of branch nodes), depth (longest path), leaves (count of leaves plus 1); leaves(t) = count(t) + 1
      • Tree capacity: depth 20 can store 2^20 - 1 ≈ 10^6 elements; short access paths compared with lists

      7. Dictionaries and Functional Arrays (Lecture 7)

      • Dictionary operations: lookup, update, delete, empty; Missing exception; abstract data types
      • Association lists: (key, value) pairs; lookup is O(n); update is O(1) (cons); space linear in updates
      • Binary search trees: keys with total order; left subtree smaller, right subtree greater; balanced → O(log n); unbalanced → O(n)
      • BST lookup: compare, go left or right; Missing exception with key; O(log n) average
      • BST update: copies path only, shares unchanged subtrees; O(log n) time and space average
      • Tree traversal: preorder, inorder, postorder (all depth-first); definitions using @; relation: inorder of BST yields sorted list
      • Efficient traversal: preord, inord, postord with accumulator; linear time; inord(t, vs) = inorder(t) @ vs
      • Arrays: conventional (imperative, in-place update) vs functional (copying, update(A, k, x) returns new array)
      • Functional arrays as binary trees: path follows binary code of subscript; always balanced; O(log n) lookup and update
      • sub function: divide subscript by 2, follow left (even) or right (odd); when guard clauses; Subscript exception
      • update function: copy path, replace leaf with branch if k=1; supports extending array; O(log n)
      • Dictionaries in order of decreasing generality, increasing efficiency: linear search → binary search trees → array subscripting

      8. Functions as Values (Lecture 8)

      • Functions as first-class values: passed as arguments, returned as results, stored in data structures; cannot test equality
      • fun notation: anonymous functions; fun x -> E; function keyword for pattern matching; equivalent to let f x = E
      • Curried functions: fun a -> fun b -> a ^ b; type string -> string -> string; partial application
      • Shorthand for curried functions: let prefix a b = a ^ b; applying prefix "Sir " yields a function
      • Curried insertion sort: insort lessequal takes ordering predicate, returns sort function; (<=) and (>=) as function arguments
      • map: apply-to-all functional; type ('a -> 'b) -> 'a list -> 'b list; nested map (map double)
      • Matrix transpose using map: transp; extract first column with map List.hd, remaining with map List.tl
      • Matrix multiplication: dotprod (dot product), matprod using map and partial application of dotprod
      • Predicate functionals: exists (lazy || short-circuit), filter (select elements), all (lazy && short-circuit)
      • Applications: member via exists, inter via filter, disjoint via all; eliminating ad-hoc function declarations

      9. Sequences, or Lazy Lists (Lecture 9)

      • Pipeline model: Producer → Filter → … → Filter → Consumer; consumer drives demand; lazy lists join stages
      • Lazy lists: possibly infinite; elements computed on demand; avoids waste for many solutions
      • OCaml lazy list type: type 'a seq = Nil | Cons of 'a * (unit -> 'a seq); unit type ()
      • Delayed evaluation: fun () -> E delays E; head and tail (forces with xf ())
      • Infinite sequences: from k generates k, k+1, k+2, ...; tail forces next element
      • Consuming sequences: get n s returns first n elements as a list; forces evaluation
      • Joining sequences: appendq (loses second if first is infinite); interleave (fair, exchanges arguments)
      • Functionals for lazy lists: filterq (forces until match found; may loop forever); iterates f x generates x, f(x), f(f(x)), ...
      • Numerical computation: Newton-Raphson square root via iterates and within (convergence test); infinite series as lazy lists
      • Key principle: force operations must be enclosed in delays to maintain laziness; get and filterq have unprotected forces

      10. Queues and Search Strategies (Lecture 10)

      • Breadth-first vs depth-first tree traversal: BFS levels first (nearest solutions); DFS left subtree first (may miss near solutions in right subtree)
      • Naive breadth-first (nbreadth): uses list and @; enormous queue, wasteful append; O(b^d) space
      • Queue ADT: qempty, qnull, qhd, deq, enq; FIFO discipline
      • Efficient functional queues: pair of lists ([x1,...,xm], [y1,...,yn]) representing x1...xm yn...y1
      • Queue operations: enq conses to rear list O(1); deq removes from front O(1) amortised; norm reverses rear when front empties
      • Amortised analysis: n enq + n deq = 2n conses total; amortised O(1) per operation; worst reversal O(n)
      • Efficient BFS with queues (breadth): uses queue type; 200x faster than nbreadth for depth 12 tree
      • Iterative deepening: repeated depth-first with increasing depth bounds; O(b^d) time (factor b/(b-1)), O(d) space; trades time for space
      • Stack ADT: LIFO discipline; lists implement stacks naturally; depth-first uses stack implicitly/explicitly
      • Survey of search methods: depth-first (stack, efficient but incomplete), breadth-first (queue, space-hungry), iterative deepening (depth-first + bounds), best-first (priority queue, heuristic)

      11. Elements of Procedural Programming (Lecture 11)

      • Procedural programming: state transformation; commands vs expressions; control structures (branching, iteration, procedures)
      • OCaml reference primitives: ref E (create), !P (dereference), P := E (assign); type 'a ref
      • References are values: can be stored in lists; p := !p + 1 increments contents; let bindings are immutable
      • Commands: C1; C2; ...; Cn evaluates sequentially, returns value of Cn; typical command returns ()
      • while loop: while B do C done; returns (); example: iterative length computation with references
      • Private persistent references: makeAccount returns withdraw function with private balance ref; closure captures reference
      • Object-oriented programming via closures: multiple accounts with independent private state; no cross-access
      • OCaml arrays: [|...|] syntax; Array.make, Array.init, Array.get, Array.set; 0-indexed; bounds-checked
      • Arrays as references holding multiple elements; int ref list vs int list ref; array of arrays for 2D
      • Mutable lists: type 'a mlist = Nil | Cons of 'a * 'a mlist ref; linked structures with references
      • OCaml vs conventional languages: explicit dereference !; V := E not V = E; safer arrays (bounds-checked); a.(i) <- v alternative syntax

      Exam technique

      1. Complexity analysis (4-6 marks): State the recurrence relation, identify the growth class, and justify with a sentence or two. For list algorithms, count cons operations or comparisons. For tree algorithms, count recursive calls with respect to tree depth/size. Always give both time and space when relevant.

      2. OCaml function writing (8-12 marks): Write clear, well-structured code with proper pattern matching. Use let rec for recursion. Handle base cases first. Use accumulators when efficiency is required. Write polymorphic functions where appropriate. Comment on crucial design decisions.

      3. Tracing/explaining code (4-6 marks): Show the step-by-step reduction. For recursive functions, show the nesting. For lazy lists, show when forces and delays occur. For exceptions, trace the handler chain.

      4. Discussion/design questions (4-6 marks): Two or three well-developed points with specific references to course concepts. Compare alternatives (e.g., BFS vs DFS vs iterative deepening). Justify design decisions.

      Key concepts to memorise

      ConceptOne-line summary
      Tail recursionRecursive call is the last operation; compiler can reuse stack frame; O(1) space
      AccumulatorExtra argument carrying partial result; enables iterative/tail-recursive style
      Big-O notationAsymptotic upper bound ignoring constant factors; f(n) = O(g(n)) if `
      :: (cons)O(1) operation prepending element to list; fundamental list constructor
      @ (append)Copies first list; O(n) time and space; avoid in recursive functions
      BST lookup/updateO(log n) average if balanced; copies path only for update; shares unchanged subtrees
      Reduction in strengthReplacing expensive operations (e.g., append) with cheap ones (e.g., cons)
      Curried functionReturns a function as result; enables partial application; type a -> b -> c
      mapApplies function to every list element; type ('a -> 'b) -> 'a list -> 'b list
      Lazy listTail wrapped in unit -> 'a seq; computed on demand; enables infinite sequences
      Functional queuePair of lists: ([front], [reversed rear]); amortised O(1) per operation
      Iterative deepeningRepeated depth-first with increasing bounds; O(b^d) time, O(d) space
      ReferenceMutable cell: ref creates, ! reads, := writes; type 'a ref
      while loopImperative iteration: while B do C done; returns ()
      ClosureFunction capturing private mutable state; e.g., makeAccount
      Depth-first searchSearches one subtree fully first; O(d) space; may miss near solutions in infinite trees
      Breadth-first searchSearches level by level; finds nearest solutions first; O(b^d) space
      Functional arrayBinary tree indexed by binary code of subscript; O(log n) lookup/update; always balanced
      mergeCombines two sorted lists in O(m+n) time; basis of merge sort
      QuicksortAverage O(n log n), worst O(n^2); pivot selection critical
      Merge sortWorst-case O(n log n); divide-and-conquer by splitting list; optimal comparison sort
    • Tripos 2021 Paper 1 Question 1 — Worked Solution

      Question: Sequences (lazy lists) and trees with integer elements.

      Given definitions:

      type iseq = Nil
        | Cons of int * (unit -> iseq)
      
      type itree = Leaf of int
        | Branch of itree * itree

      (a) Merge two ascending sequences [5 marks]

      Write merge2 that takes two ascending sequences and produces an ascending sequence of all elements.

      Solution:

      let rec merge2 s1 s2 =
        match s1, s2 with
        | Nil, _ -> s2
        | _, Nil -> s1
        | Cons (x, xf), Cons (y, yf) ->
            if x <= y then
              Cons (x, fun () -> merge2 (xf ()) s2)
            else
              Cons (y, fun () -> merge2 s1 (yf ()))

      Explanation: We pattern-match on both sequences simultaneously. If either is Nil, we return the other (this covers both base cases). When both are non-empty, we compare the heads. The smaller head is emitted, and the tail is wrapped in fun () -> to delay the recursive call — this preserves laziness so elements are only computed on demand. The sequence with the larger head is passed through untouched (not forced), since its head may still be needed in later comparisons. Each step does constant work, so accessing the first n elements takes O(n) time — the same asymptotic cost as merging two ordinary sorted lists.

      (b)(i) Sequence equality [5 marks]

      Define equal_seq that compares two sequences for equality (corresponding elements equal).

      Solution:

      let rec equal_seq s1 s2 =
        match s1, s2 with
        | Nil, Nil -> true
        | Cons (x, xf), Cons (y, yf) ->
            x = y && equal_seq (xf ()) (yf ())
        | _ -> false

      Explanation: The function forces both sequences element by element. If both are Nil simultaneously, they are equal. If one is Nil and the other is Cons, they have different lengths — unequal. If both are Cons, we check the heads for equality and then recursively compare the tails. The && operator is short-circuiting: if x = y is false, we return false immediately without forcing the tails. We force tails with xf () and yf () only when heads match. Since we must examine each pair of corresponding elements, the time complexity is O(n) where n is the length of the shorter sequence (or infinite if they match forever).

      (b)(ii) Non-terminating equality [3 marks]

      Define sequences s1 and s2 for which equal_seq s1 s2 does not terminate.

      Solution:

      let rec s1 = Cons (1, fun () -> s1)
      let rec s2 = Cons (1, fun () -> s2)

      Here s1 and s2 are infinite sequences of all 1s. The call equal_seq s1 s2 never terminates because every pair of corresponding elements is equal, so equal_seq forces another pair, which is also equal, and so on forever. The function attempts to verify equality of the entire infinite sequence, which is impossible without additional information.

      Alternatively, any two infinite sequences that are element-wise equal will cause non-termination. For instance:

      let rec s1 = Cons (1, fun () -> Cons (2, fun () -> s1))
      let rec s2 = Cons (1, fun () -> Cons (2, fun () -> s2))

      Both alternate 1, 2, 1, 2, ...equal_seq s1 s2 will loop forever checking matching 1s and 2s.

      (c)(i) Computing the fringe of a tree [5 marks]

      Define fringe that returns the left-to-right sequence of leaf values. Type: val fringe : itree -> iseq.

      Solution:

      let rec fringe t =
        match t with
        | Leaf v -> Cons (v, fun () -> Nil)
        | Branch (left, right) ->
            appendq (fringe left) (fringe right)
      
      and appendq s1 s2 =
        match s1 with
        | Nil -> s2
        | Cons (x, xf) -> Cons (x, fun () -> appendq (xf ()) s2)

      Explanation: The fringe of a Leaf is a one-element sequence followed by Nil. For a Branch, we need to concatenate the fringes of the left and right subtrees. We use a helper appendq that lazily concatenates two sequences — it forces the first sequence element by element, wrapping each recursive call in fun () -> to preserve laziness. Once the first sequence is exhausted, it returns the second. This ensures that accessing fringe elements on demand does not force the entire tree traversal at once. The type is val fringe : itree -> iseq as required.

      (c)(ii) Comparing fringes of two trees [2 marks]

      Write equal_fringes that determines whether two trees have equal fringes.

      Solution:

      let equal_fringes t1 t2 =
        equal_seq (fringe t1) (fringe t2)

      Explanation: We simply compute the fringe of each tree and compare sequences using equal_seq. Thanks to laziness, we only compute as much of each fringe as needed to find a difference. If the fringes differ at position k, we compute only the first k+1 elements of each fringe. This is efficient because we do not materialise entire fringes into lists — we compare on demand.

    • Tripos 2021 Paper 1 Question 2 — Worked Solution

      Question: A W × H matrix can be represented by a flat list concatenating the rows in order. For three alternative representations, state the type T, give create and get, and state asymptotic complexity of get.

      Given functional array code:

      type 'a tree = Lf | Br of 'a * 'a tree * 'a tree
      exception Subscript
      
      let rec sub = function
        | Lf, _ -> raise Subscript
        | Br (v, t1, t2), 1 -> v
        | Br (v, t1, t2), k when k mod 2 = 0 -> sub (t1, k / 2)
        | Br (v, t1, t2), k -> sub (t2, k / 2)
      
      let rec update = function
        | Lf, k, w ->
            if k = 1 then Br (w, Lf, Lf)
            else raise Subscript
        | Br (v, t1, t2), k, w ->
            if k = 1 then Br (w, t1, t2)
            else if k mod 2 = 0 then Br (v, update (t1, k / 2, w), t2)
            else Br (v, t1, update (t2, k / 2, w))

      (a) A list of lists [5 marks]

      Type T: float list list

      create:

      let rec create w = function
        | [] -> []
        | m ->
            let row = take w m in
            let rest = drop w m in
            row :: create w rest

      This uses take and drop (as defined in the course) to partition the flat list into rows of width w. Each recursive step extracts one row. This is O(W × H) time since we must process every element of the flat list.

      get:

      let rec get r c m =
        match m with
        | [] -> raise (Invalid_argument "index out of bounds")
        | row :: rest ->
            if r = 0 then
              let rec nth i = function
                | [] -> raise (Invalid_argument "index out of bounds")
                | x :: _ when i = 0 -> x
                | _ :: xs -> nth (i - 1) xs
              in
              nth c row
            else
              get (r - 1) c rest

      Asymptotic complexity of get: O(r + W) in the worst case. We scan r rows (skipping r × W elements), then scan c elements in the target row. Since r < H and c < W, this is O(H + W).

      (b) An array of arrays [6 marks]

      Type T: float array array

      create:

      let create w m =
        let flat = Array.of_list m in
        let h = Array.length flat / w in
        Array.init h (fun i ->
          Array.init w (fun j ->
            flat.(i * w + j)
          )
        )

      Explanation: We first convert the input list to a flat array for O(1) indexed access. Then we allocate an h × w array of arrays, computing each element’s index in the flat array as i * w + j. This is O(W × H) time.

      get:

      let get r c m =
        m.(r).(c)

      Asymptotic complexity of get: O(1). Array indexing is constant-time: the outer array lookup m.(r) and the inner array lookup .(c) are both direct indexed accesses.

      (c) A functional array of functional arrays [9 marks]

      Type T: float tree tree

      A functional array of functional arrays: the outer tree maps row index r+1 to the inner tree (where indices in functional arrays start at 1), and the inner tree maps column index c+1 to the float value. The element at matrix position (r, c) corresponds to index (r+1) in the outer tree and (c+1) in the inner tree.

      create:

      let create w m =
        let n = List.length m in
        let h = n / w in
        let rec build_rows i rows acc =
          if i >= h then acc
          else
            let row_start = i * w in
            let rec build_row j acc_row =
              if j >= w then acc_row
              else
                let v = List.nth m (row_start + j) in
                build_row (j + 1) (update (acc_row, j + 1, v))
            in
            let row = build_row 0 Lf in
            build_rows (i + 1) rows (update (acc, i + 1, row))
        in
        build_rows 0 h Lf

      Explanation (more efficient version): Rather than repeatedly calling update (which copies the path each time, giving O(n log n) overall), we can construct the functional array directly using a more efficient approach. The coursework version using update is acceptable but expensive. Here is a cleaner version:

      let create w m =
        let n = List.length m in
        let h = n / w in
        let rec of_list start len =
          if len = 0 then Lf
          else if len = 1 then Br (List.nth m start, Lf, Lf)
          else
            let mid = len / 2 in
            let left = of_list start mid in
            let right = of_list (start + mid + 1) (len - mid - 1) in
            Br (List.nth m (start + mid), left, right)
        in
        let rec make_rows i =
          if i >= h then Lf
          else
            let row = of_list (i * w) w in
            update (make_rows (i + 1), i + 1, row)
        in
        make_rows 0

      But the simplest approach building row-by-row with update is:

      let create w m =
        let n = List.length m in
        let h = n / w in
        let rec loop i acc =
          if i = h then acc
          else
            let rec build_row j row_acc =
              if j = w then row_acc
              else
                let idx = i * w + j in
                let v = List.nth m idx in
                build_row (j + 1) (update (row_acc, j + 1, v))
            in
            let row = build_row 0 Lf in
            loop (i + 1) (update (acc, i + 1, row))
        in
        loop 0 Lf

      Building the outer functional array takes O(H × log H) time (each of H row updates copies O(log H) path nodes). Building each inner row takes O(W × log W) time for each of H rows. Total: O(H × W × log W + H × log H).

      get:

      let get r c m =
        let row = sub (m, r + 1) in
        sub (row, c + 1)

      Asymptotic complexity of get: O(log H + log W). We first look up the row tree at index r+1, which takes O(log H) time following the binary path. Then we look up the inner tree at index c+1, taking O(log W) time. Both sub operations follow paths proportional to the depth of balanced trees, giving O(log H + log W) total. Since H × W = n, this is O(log n) where n is the total number of elements.

    • Tripos 2022 Paper 1 Question 1 — Worked Solution

      Question: A single-player word-guessing game. The player guesses a letter c and position i. Response: Green (correct letter, correct position), Amber (correct letter, wrong position), or Black (not in word). No repeated letters in the target word.

      Given types and helper:

      type word = char list
      type guess = char * int
      type guesses = guess list
      val prune_guesses : guesses -> guesses

      prune_guesses removes all but the most recent guess for each character.

      (a) mapi and lookfor [4 marks]

      mapi:

      let rec mapi f l =
        let rec go i = function
          | [] -> []
          | x :: xs -> f i x :: go (i + 1) xs
        in
        go 0 l

      Explanation: mapi is like map but the function f also receives the element’s index (starting from 0). We use a helper function go with an accumulating index parameter. Each recursive step applies f i x and increments the index. This is O(n) time.

      lookfor:

      let rec lookfor y = function
        | [] -> None
        | (a, b) :: rest ->
            if b = y then Some a
            else lookfor y rest

      Explanation: lookfor y l searches l for a pair (a, b) where b = y. If found, returns Some a; otherwise None. This is a linear search, O(n) time. The type is val lookfor : 'a -> ('b * 'a) list -> 'b option.

      (b) respond function [8 marks]

      Write respond : word -> guesses -> responses that gives feedback about game progress.

      Solution:

      type response = Green of int | Amber of int | Black of char
      type responses = response list
      
      let respond target guesses =
        let pruned = prune_guesses guesses in
        mapi (fun i c ->
          match lookfor c pruned with
          | None -> Black c
          | Some pos ->
              if pos = i then Green i
              else Amber i
        ) target

      Explanation: First we prune the guesses to keep only the most recent guess per character. Then we use mapi to iterate over the target word with positions. For each character c at position i in the target:

      • If the character was never guessed (lookfor returns None), the response is Black c — the player doesn’t know this letter yet.
      • If the player guessed this character at position i, the response is Green i — correct letter in the correct position.
      • If the player guessed this character at some other position, the response is Amber i — the letter is in the word but at a different position.

      The responses type captures the three possible outcomes. This is O(n × m) where n is word length and m is the number of guesses (since lookfor does linear search).

      (c)(i) create_game with turn limit [6 marks]

      Define create_game : word -> (guess -> responses) that returns a function g usable for at most six tries, then raises Out_of_turns.

      Solution:

      exception Out_of_turns
      
      let create_game target =
        let guesses = ref [] in
        let turns = ref 0 in
        fun (c, pos) ->
          if !turns >= 6 then raise Out_of_turns
          else begin
            turns := !turns + 1;
            guesses := (c, pos) :: !guesses;
            respond target !guesses
          end

      Explanation: We use two mutable references: guesses accumulates the guess history (most recent first, matching the guesses type convention), and turns counts how many tries have been made. The returned closure:

      1. Checks if six turns have been used — if so, raises Out_of_turns.
      2. Increments the turn counter.
      3. Prepends the new guess to the guesses list.
      4. Calls respond with the updated guesses.

      The closure captures the mutable references, so each call to g updates the same internal state. This is the private-state pattern from Lecture 11 (analogous to makeAccount). The function remains purely functional in its interface — the caller only sees a function from guess to responses.

      (c)(ii) Example usage [2 marks]

      let g = create_game ['a'; 'p'; 'p'; 'l'; 'e']
      
      (* Try 1: guess 'a' at position 0 *)
      g ('a', 0)
      (* Result: [Green 0; Black 'p'; Black 'p'; Black 'l'; Black 'e'] *)
      
      (* Try 2: guess 'p' at position 1 *)
      g ('p', 1)
      (* Result: [Green 0; Green 1; Amber 2; Black 'l'; Black 'e'] *)
      (* Note: position 2 shows Amber because 'p' is at position 1 but also at position 2 *)
      
      (* Try 3: guess 'x' at position 0 *)
      g ('x', 0)
      (* Result: [Green 0; Green 1; Amber 2; Black 'l'; Black 'e'] *)
      (* No new information about 'x' since it's not in the word *)

      After 6 tries, further calls to g raise Out_of_turns.

    • Tripos 2022 Paper 1 Question 2 — Worked Solution

      Question: Represent sets of integers as lists of intervals.

      type intset = (int * int) list

      An interval list is in standard form if it is an ascending sequence of non-empty intervals that cannot be merged. Example: {1,2,3,9,10,11,12} is [(1,3);(9,12)].

      (a)(i) Test standard form [4 marks]

      let rec is_standard = function
        | [] -> true
        | [(a, b)] -> a <= b
        | (a1, b1) :: (a2, b2) :: rest ->
            a1 <= b1 && b1 + 1 < a2 && is_standard ((a2, b2) :: rest)

      Explanation: An interval list is in standard form if three conditions hold:

      1. Every interval is non-empty: for each (a, b), we must have a ≤ b.
      2. Intervals are ascending: the start of each interval is greater than the end of the previous interval plus 1 — if b1 + 1 ≥ a2, the intervals (a1, b1) and (a2, b2) overlap or touch and should be merged.
      3. No adjacent intervals can be merged: this is exactly b1 + 1 < a2.

      We check these conditions recursively. The singleton case only checks a ≤ b. For two or more intervals, we verify the first interval is non-empty (a1 ≤ b1), that it cannot merge with the next (b1 + 1 < a2), and recursively check the rest. Time complexity: O(n) where n is the number of intervals.

      (a)(ii) Add interval to standard form [4 marks]

      let rec add_interval (a, b) = function
        | [] -> [(a, b)]
        | (a', b') :: rest ->
            if b + 1 < a' then (a, b) :: (a', b') :: rest
            else if b' + 1 < a then (a', b') :: add_interval (a, b) rest
            else
              let a'' = min a a' in
              let b'' = max b b' in
              add_interval (a'', b'') rest

      Explanation: We insert (a, b) while maintaining standard form. There are three cases when comparing with the head interval (a', b'):

      1. No overlap, new interval before: If b + 1 < a', the new interval is completely before the head — prepend it and we’re done.

      2. No overlap, new interval after: If b' + 1 < a, the head interval is completely before the new one — keep the head and recursively try to insert into the rest.

      3. Overlap or touch: The intervals can be merged. We compute the merged interval (min a a', max b b') and recursively insert this merged interval into rest. This may trigger further merges with subsequent intervals.

      This is O(n) time in the worst case (when the new interval merges with all existing intervals, reducing the list to a single interval).

      (a)(iii) Convert to standard form [2 marks]

      let standardize intset =
        List.fold_left (fun acc iv -> add_interval iv acc) [] intset

      Explanation: We start with an empty list (trivially in standard form) and repeatedly add each interval using add_interval. Since add_interval maintains standard form, the final result is in standard form. This is O(n^2) worst-case if each addition triggers a cascade of merges, but typically much faster. This is equivalent to List.fold_left — the OCaml standard library function.

      (a)(iv) Equality test [2 marks]

      let equal s1 s2 =
        let s1' = standardize s1 in
        let s2' = standardize s2 in
        s1' = s2'

      Explanation: Two interval lists represent the same set if their standard forms are identical (element-wise equal as lists of pairs). We standardise both and use polymorphic equality = to compare the resulting lists. Since standard form is a canonical representation, this correctly determines set equality. The time complexity is dominated by the standardisation step: O(n^2) worst-case where n is the number of intervals. We could also write it without standardising both by doing a single pass comparison if both inputs are known to be in standard form.

      (b) Intersection of integer sets [8 marks]

      Write inter : intset -> intset -> intset assuming both arguments are in standard form.

      let rec inter s1 s2 =
        match s1, s2 with
        | [], _ -> []
        | _, [] -> []
        | (a1, b1) :: rest1, (a2, b2) :: rest2 ->
            if b1 < a2 then
              inter rest1 s2
            else if b2 < a1 then
              inter s1 rest2
            else
              let a = max a1 a2 in
              let b = min b1 b2 in
              (a, b) :: inter
                (if b1 = b then rest1
                 else (b + 1, b1) :: rest1)
                (if b2 = b then rest2
                 else (b + 1, b2) :: rest2)

      Explanation: We process both sorted interval lists simultaneously, similar to merging two sorted lists. For the head intervals (a1, b1) and (a2, b2):

      1. No overlap (first entirely before second): If b1 < a2, the first interval cannot intersect with the second or any later interval (since s2 is ascending). Discard the first interval and continue.

      2. No overlap (second entirely before first): If b2 < a1, discard the second interval.

      3. Overlap: The intersection is (max a1 a2, min b1 b2). We output this interval. Then we need to handle the “leftover” parts of the original intervals that extend beyond the intersection:

        • If b1 > b (the first interval extends beyond the intersection), we keep (b + 1, b1) and prepend it to rest1. Otherwise we move to rest1.
        • Similarly for the second interval: if b2 > b, keep (b + 1, b2) and prepend it to rest2.

      This processes each interval at most once. The time complexity is O(n1 + n2) where n1 and n2 are the numbers of intervals in s1 and s2 respectively. The result is guaranteed to be in standard form (ascending, non-empty, non-mergeable) because the intersection of intervals inherits these properties.

    • Tripos 2023 Paper 1 Question 1 — Worked Solution

      Question: Sort elements by frequency using run-length encoding (RLE). An RLE representation has type ('a * int) list where the int is the repetition count.

      let input1 = [4; 1; 3; 3; 2; 3; 1]
      let input2 = ['a'; 'e'; 'i'; 'e'; 'o'; 'e'; 'i']

      (a) Efficient list reversal [2 marks]

      Define rev that reverses a list in O(n) time.

      let rev xs =
        let rec rev_app xs acc =
          match xs with
          | [] -> acc
          | x :: rest -> rev_app rest (x :: acc)
        in
        rev_app xs []

      Explanation: We use the standard accumulator technique from Lecture 3. rev_app xs acc reverses xs and prepends it to acc. Each element is consed onto the accumulator, so each step does O(1) work. Total: O(n) time, O(1) stack space (tail-recursive). This is the efficient rev introduced in the course — unlike nrev which uses @ and takes O(n^2).

      (b) Sorting function [6 marks]

      Define sort : ('a -> 'a -> int) -> 'a list -> 'a list using an algorithm of your choice.

      Solution (using merge sort — O(n log n) worst-case):

      let rec merge cmp l1 l2 =
        match l1, l2 with
        | [], l -> l
        | l, [] -> l
        | h1 :: t1, h2 :: t2 ->
            if cmp h1 h2 <= 0 then h1 :: merge cmp t1 l2
            else h2 :: merge cmp l1 t2
      
      let rec take n = function
        | [] -> []
        | x :: xs -> if n > 0 then x :: take (n - 1) xs else []
      
      let rec drop n = function
        | [] -> []
        | x :: xs -> if n > 0 then drop (n - 1) xs else x :: xs
      
      let rec sort cmp = function
        | [] -> []
        | [x] -> [x]
        | xs ->
            let n = List.length xs / 2 in
            let left = sort cmp (take n xs) in
            let right = sort cmp (drop n xs) in
            merge cmp left right

      Explanation: We implement top-down merge sort (Lecture 5). The comparator function returns ≤ 0 if the first argument should come before (or equal to) the second, and > 0 otherwise. merge combines two sorted lists in O(m+n) time. sort recursively splits the list in half (via take/drop), sorts each half, and merges. The recurrence T(n) = 2T(n/2) + O(n) solves to O(n log n) in the worst case. The comparator parameter makes it polymorphic: we can sort by any ordering.

      Alternative (quicksort — simpler but O(n^2) worst-case):

      let rec sort cmp = function
        | [] -> []
        | x :: xs ->
            let smaller = List.filter (fun y -> cmp y x <= 0) xs in
            let larger = List.filter (fun y -> cmp y x > 0) xs in
            sort cmp smaller @ [x] @ sort cmp larger

      Either approach is acceptable. The merge sort version is safer but requires take and drop.

      (c) RLE encode and decode [6 marks]

      let rle_encode xs =
        let rec go current count = function
          | [] -> [(current, count)]
          | x :: rest ->
              if x = current then go current (count + 1) rest
              else (current, count) :: go x 1 rest
        in
        match xs with
        | [] -> []
        | x :: rest -> go x 1 rest
      
      let rle_decode =
        let rec repeat x n acc =
          if n <= 0 then acc
          else repeat x (n - 1) (x :: acc)
        in
        let rec go = function
          | [] -> []
          | (x, n) :: rest ->
              let repeated = repeat x n [] in
              repeated @ go rest
        in
        go

      Better version of rle_decode (without @ for efficiency):

      let rle_decode rle =
        let rec repeat x n acc =
          if n <= 0 then acc
          else repeat x (n - 1) (x :: acc)
        in
        let rec go acc = function
          | [] -> rev acc
          | (x, n) :: rest ->
              go (repeat x n acc) rest
        in
        go [] rle

      Explanation: rle_encode assumes the input list is sorted (so equal elements are adjacent). It walks the list maintaining current (the element being counted) and count (how many times it’s been seen). When the element changes, it emits the pair and resets. The function runs in O(n) time where n is the list length.

      rle_decode reverses the process: for each (x, n) pair, it produces n copies of x. The helper repeat builds the copies (with an accumulator to make it tail-recursive, though the result is reversed). We use rev at the end to correct the order. Total time is O(N) where N is the total number of elements in the decoded list. Type: val rle_encode : 'a list -> ('a * int) list and val rle_decode : ('a * int) list -> 'a list.

      (d) Frequency sort [6 marks]

      Define freq_sort that sorts elements in ascending order of their frequency of occurrence.

      let freq_sort cmp xs =
        let sorted = sort cmp xs in
        let encoded = rle_encode sorted in
        let by_freq = sort (fun (_, c1) (_, c2) ->
          if c1 < c2 then -1
          else if c1 > c2 then 1
          else 0
        ) encoded in
        rle_decode by_freq

      Explanation: The strategy has four steps:

      1. Sort the original list by value (using cmp) so equal elements are adjacent and can be run-length encoded.
      2. Encode the sorted list into RLE form: each pair (value, count).
      3. Re-sort the RLE pairs by frequency (the count component), in ascending order. We write a comparator that compares the second element of each pair. This groups elements from least frequent to most frequent.
      4. Decode the re-sorted RLE back into a flat list.

      For input1 = [4; 1; 3; 3; 2; 3; 1]:

      • After value-sort: [1; 1; 2; 3; 3; 3; 4]
      • RLE: [(1,2); (2,1); (3,3); (4,1)]
      • After frequency-sort: [(2,1); (4,1); (1,2); (3,3)] (frequency 1, then 2, then 3)
      • Decode: [2; 4; 1; 1; 3; 3; 3]

      This matches the required output. Elements with the same frequency keep their relative order from the value-sort step because sort is stable in our merge sort implementation (or we could add tie-breaking to the comparator). Time complexity: O(n log n) dominated by the two sorting steps. Space: O(n).

    • Tripos 2023 Paper 1 Question 2 — Worked Solution

      Question: Quadtrees of points.

      type point = int * int
      type qt = Empty | Quad of qt * qt * point * qt * qt

      A Quad(nw, ne, (x,y), sw, se) contains points in quadrants around (x,y). x is the right bound of points in nw and sw; left bound of points in ne and se. y is the upper bound of points in sw and se; lower bound of points in nw and ne.

      (a) compare_range [2 marks]

      type range = int * int
      type rel = LT | IN | GT
      
      let compare_range v (lo, hi) =
        if v < lo then LT
        else if v > hi then GT
        else IN

      Explanation: We compare v against the inclusive range [lo, hi]. Three cases: below the range (LT), within it (IN), or above it (GT). The function is O(1). For compare_range 3 (2,5), since 2 ≤ 3 ≤ 5, the result is IN.

      (b) has_point [8 marks]

      Efficiently search a quadtree for a specific point.

      let rec has_point (px, py) = function
        | Empty -> false
        | Quad (nw, ne, (x, y), sw, se) ->
            if px = x && py = y then true
            else if px <= x then
              if py <= y then has_point (px, py) nw
              else has_point (px, py) sw
            else
              if py <= y then has_point (px, py) ne
              else has_point (px, py) se

      Explanation: The quadtree partitions 2D space around the point (x, y). To search:

      1. If the tree is Empty, the point is not present.

      2. If the current node’s point matches (px, py), return true.

      3. Otherwise, determine which quadrant the search point falls into:

        • North-West (nw): px ≤ x and py ≤ y (left of or equal to x, below or equal to y).
        • South-West (sw): px ≤ x and py > y.
        • North-East (ne): px > x and py ≤ y.
        • South-East (se): px > x and py > y.
      4. Recursively search only that one quadrant. This is the key to efficiency: we prune three-quarters of the remaining space at each step. For a balanced quadtree with n points, the depth is O(log n), so search takes O(log n) time — analogous to binary search in a 1D BST but extended to 2D.

      Note the on the boundaries: points with px = x go to the left (nw or sw), and points with py = y go below (nw or ne). This matches the problem statement (“x is the right bound of points in nw and sw” meaning nw/sw contain points where px ≤ x).

      (c) has_point_in [10 marks]

      Search for any point within a rectangular region.

      type rectangle = point * point
      
      let rec has_point_in ((x1, y1), (x2, y2)) = function
        | Empty -> false
        | Quad (nw, ne, (x, y), sw, se) ->
            let px_in = compare_range (fst (x, y)) (x1, x2) in
            let py_in = compare_range (snd (x, y)) (y1, y2) in
            if px_in = IN && py_in = IN then true
            else
              let check_nw = (x1 <= x && y1 <= y) &&
                has_point_in ((x1, y1), (x2, y2)) nw in
              let check_ne = (x2 > x && y1 <= y) &&
                has_point_in ((x1, y1), (x2, y2)) ne in
              let check_sw = (x1 <= x && y2 > y) &&
                has_point_in ((x1, y1), (x2, y2)) sw in
              let check_se = (x2 > x && y2 > y) &&
                has_point_in ((x1, y1), (x2, y2)) se in
              check_nw || check_ne || check_sw || check_se

      Better, more readable version:

      let rec has_point_in ((x1, y1), (x2, y2)) = function
        | Empty -> false
        | Quad (nw, ne, (x, y), sw, se) ->
            if x1 <= x && x <= x2 && y1 <= y && y <= y2 then true
            else
              let rec check_when cond subtree =
                cond && has_point_in ((x1, y1), (x2, y2)) subtree
              in
              check_when (x1 <= x && y1 <= y) nw ||
              check_when (x2 > x && y1 <= y) ne ||
              check_when (x1 <= x && y2 > y) sw ||
              check_when (x2 > x && y2 > y) se

      Explanation: The algorithm is a 2D range query on a quadtree:

      1. If the tree is Empty, there is no point in the region.

      2. If the current node’s point (x, y) lies within the rectangular region [x1, x2] × [y1, y2], return true immediately.

      3. Otherwise, we need to search the quadrants. But we only search a quadrant if it could contain points in the region. The condition for searching each quadrant is that the quadrant’s bounding box overlaps with the query rectangle:

        • NW (points with px ≤ x, py ≤ y): overlaps if x1 ≤ x (region extends into left half) and y1 ≤ y (region extends into lower half).
        • NE (points with px > x, py ≤ y): overlaps if x2 > x (region extends into right half) and y1 ≤ y.
        • SW (points with px ≤ x, py > y): overlaps if x1 ≤ x and y2 > y.
        • SE (points with px > x, py > y): overlaps if x2 > x and y2 > y.
      4. The || operator is short-circuiting: we stop searching as soon as we find a point in the region.

      Efficiency: In the worst case, the query rectangle covers the entire space and we must search all n points — O(n) time. In practice, for small query regions, we prune most of the tree. If the quadtree is balanced, the number of visited nodes is proportional to the number of points in (or near) the region plus O(log n) for the traversal. The space complexity is O(depth) for the recursion stack. The function correctly returns true if and only if the quadtree contains at least one point lying within the specified rectangular region.

    • Tripos 2024 Paper 1 Question 1 — Worked Solution

      Question: Statistical analysis of 150 students’ exam results. Each student answered 6 of 10 questions. Zero indicates not attempted.

      type marks = int list
      let results : marks list = [
        [ 30; 25; 20; 0; 0; 18; 30; 0; 0; 8 ];
        [ 27; 0; 18; 9; 0; 30; 28; 0; 0; 17 ];
        (* ... 147 more rows ... *)
      ]

      Mean and standard deviation formulas:

      xˉ=1ni=0n1xis=1n1i=0n1(xixˉ)2\bar{x} = \frac{1}{n}\sum_{i=0}^{n-1} x_i \qquad s = \sqrt{\frac{1}{n-1}\sum_{i=0}^{n-1} (x_i - \bar{x})^2}

      (a) fold, map, filter [6 marks]

      let rec fold f acc = function
        | [] -> acc
        | x :: xs -> fold f (f acc x) xs
      
      let rec map f = function
        | [] -> []
        | x :: xs -> f x :: map f xs
      
      let rec filter p = function
        | [] -> []
        | x :: xs ->
            if p x then x :: filter p xs
            else filter p xs

      Explanation: These are the standard list functionals from Lecture 8. fold (left fold) processes the list from left to right, accumulating a result. map applies f to each element. filter keeps elements satisfying p. Note that fold as defined here is not tail-recursive — the recursive call is the last operation syntactically but the accumulator is passed as argument. For a tail-recursive version:

      let rec fold f acc = function
        | [] -> acc
        | x :: xs -> fold f (f acc x) xs

      This is actually tail-recursive because the result of the recursive call is returned directly (no further computation). So O(n) time, O(1) space. map is not tail-recursive but can be made so with an accumulator (at the cost of reversing). filter is also not tail-recursive.

      (b) Mean and standard deviation [8 marks]

      type stats_result = Stats of float * float | NoAttempts
      
      let analyse marks =
        let attempted = filter (fun x -> x <> 0) marks in
        let n = List.length attempted in
        if n < 2 then NoAttempts
        else
          let sum = fold (fun acc x -> acc +. float_of_int x) 0.0 attempted in
          let mean = sum /. float_of_int n in
          let sq_diffs = map (fun x ->
            let diff = float_of_int x -. mean in
            diff *. diff
          ) attempted in
          let sum_sq = fold (fun acc x -> acc +. x) 0.0 sq_diffs in
          let std = sqrt (sum_sq /. float_of_int (n - 1)) in
          Stats (mean, std)

      Explanation: We:

      1. Filter out zeros (unattempted questions) from the marks list.
      2. Count how many marks remain. If fewer than 2, the standard deviation is undefined (division by n-1 would be division by zero or meaningless), so we return NoAttempts.
      3. Compute the sum of marks using fold, converting to float for the calculation.
      4. Compute the mean: sum / n.
      5. Compute the sum of squared differences from the mean using map and fold.
      6. Compute standard deviation: sqrt(sum_sq / (n - 1)) (sample standard deviation, as per the formula using n-1).

      The stats_result type captures the possibility of an undefined result (fewer than 2 attempts). The function handles edge cases gracefully rather than raising an exception.

      Time complexity: O(m) where m is the number of marks (filter is O(m), length is O(m), fold is O(m), map is O(m), second fold is O(m) — each pass is linear, giving O(m) overall with a constant factor of about 5). Space: O(m) for attempted and sq_diffs lists. Could be reduced to O(1) by combining passes, but clarity matters more here.

      (c) Per-question statistics [6 marks]

      let nth n xs =
        let rec go i = function
          | [] -> None
          | x :: rest ->
              if i = n then Some x
              else go (i + 1) rest
        in
        go 0 xs
      
      let qmean q results =
        let q_marks = filter (fun x -> x <> 0)
          (map (fun row ->
            match nth q row with
            | None -> 0
            | Some v -> v
          ) results) in
        let n = List.length q_marks in
        if n = 0 then 0.0
        else
          let sum = fold (fun acc x -> acc +. float_of_int x) 0.0 q_marks in
          sum /. float_of_int n
      
      let qstd q results =
        let q_marks = filter (fun x -> x <> 0)
          (map (fun row ->
            match nth q row with
            | None -> 0
            | Some v -> v
          ) results) in
        let n = List.length q_marks in
        if n < 2 then 0.0
        else
          let sum = fold (fun acc x -> acc +. float_of_int x) 0.0 q_marks in
          let mean = sum /. float_of_int n in
          let sq_diffs = map (fun x ->
            let diff = float_of_int x -. mean in
            diff *. diff
          ) q_marks in
          let sum_sq = fold (fun acc x -> acc +. x) 0.0 sq_diffs in
          sqrt (sum_sq /. float_of_int (n - 1))

      Explanation: nth n xs returns Some v if the list has an element at zero-based index n, or None if the list is too short. We use option rather than raising an exception, which is safer and more idiomatic.

      For qmean q results:

      1. Extract the qth mark from each student’s row using map with nth. If a student’s row is shorter than q+1 (shouldn’t happen with 10 questions, but defensive), we treat it as 0.
      2. Filter out zeros (unattempted).
      3. Compute the mean, handling the empty case.

      For qstd q results, the same pattern but with the standard deviation formula.

      The type val nth : int -> 'a list -> 'a option returns None for out-of-bounds access, which is more idiomatic OCaml than raising an exception. The question functions have types val qmean : int -> marks list -> float and val qstd : int -> marks list -> float.

      Time complexity for each: O(S) where S is the number of students (150), since we process each student’s row once for extraction and once for computation. The nth function is O(q) in the worst case per call, so total extraction is O(S × Q) where Q = 10.

    • Tripos 2024 Paper 1 Question 2 — Worked Solution

      Question: Prime number testing and fold functions.

      (a) Trial division primality test [8 marks]

      let is_prime n =
        if n < 2 then false
        else
          let rec try_div d =
            if d * d > n then true
            else if n mod d = 0 then false
            else try_div (d + 1)
          in
          try_div 2

      Explanation: For n < 2, there are no primes (1 is not prime by definition). For n ≥ 2, we use trial division. The key insight: if n is composite, it has a divisor d with 2 ≤ d ≤ √n. Equivalently, if no divisor d satisfies d² ≤ n, then n is prime. We test d = 2, 3, 4, ... checking divisibility with n mod d. Using d * d > n avoids floating-point square roots while giving the same bound because d > √n is equivalent to d² > n.

      The function terminates because d strictly increases each step. The worst case is when n is prime: we test all numbers up to √n, giving O(√n) time complexity. The space complexity is O(1) — this is iterative (tail-recursive) since the recursive call is the last operation. We could make this faster by only testing odd divisors after checking d = 2 (since even numbers > 2 can’t be prime divisors), but the question specifies checking p² ≤ n which is satisfied by this implementation.

      (b)(i) fold_range [4 marks]

      let rec fold_range a b f acc =
        if a > b then acc
        else fold_range (a + 1) b f (f a acc)

      Explanation: fold_range a b f acc applies f n for each n from a to b inclusive, accumulating results. The base case: when a > b, the range is exhausted, return acc. Otherwise, apply f a acc and recurse on a+1. This is tail-recursive: the recursive call is the last operation (the result of fold_range is returned directly). The type is val fold_range : int -> int -> (int -> 'a -> 'a) -> 'a -> 'a. For the example fold_range 1 3 (+) 10:

      • fold_range 1 3 (+) 10fold_range 2 3 (+) (10 + 1)fold_range 2 3 (+) 11
      • fold_range 3 3 (+) (11 + 2)fold_range 3 3 (+) 13
      • fold_range 4 3 (+) (13 + 3)16

      Time: O(b - a + 1) — linear in the size of the range. Space: O(1) — tail-recursive.

      (b)(ii) fold (list fold) [2 marks]

      let rec fold f acc = function
        | [] -> acc
        | x :: xs -> fold f (f acc x) xs

      Explanation: This is a left fold (sometimes called foldl). It processes list elements from left to right, accumulating the result. Like fold_range, this is tail-recursive. The type is val fold : ('a -> 'b -> 'a) -> 'a -> 'b list -> 'a. For example, fold (+) 0 [1; 2; 3] computes ((0 + 1) + 2) + 3 = 6.

      (b)(iii) Complexity analysis [3 marks]

      Time complexity: O(n) where n is the length of the list. Each element is processed exactly once, and f acc x is assumed to take O(1) time (or whatever time f takes, multiplied by n).

      Space complexity: O(1) stack space. The function is tail-recursive: the recursive call fold f (f acc x) xs is in tail position — its result is returned directly with no further computation. The OCaml compiler can optimise this to reuse the same stack frame, so the recursion does not consume stack space proportional to the list length.

      This contrasts with a non-tail-recursive fold (which would nest operations and use O(n) stack space). The tail-recursive version is suitable for processing long lists without risking stack overflow.

      (c) all_primes [3 marks]

      let all_primes a b =
        fold_range a b (fun n acc ->
          if is_prime n then n :: acc
          else acc
        ) []

      Explanation: We use fold_range to iterate through integers from a to b. For each n, if is_prime n returns true, we cons n onto the accumulator. Since we’re folding from a to b, the primes appear in reverse order in the result list (because a is processed first and consed, then a+1 is consed on top, etc.). If ascending order is desired, we would fold from b down to a, or reverse the result.

      Alternative (with List.rev for correct order):

      let all_primes a b =
        List.rev (fold_range a b (fun n acc ->
          if is_prime n then n :: acc else acc
        ) [])

      The type is: val all_primes : int -> int -> int list.

      Time complexity: O((b-a) × √b) in the worst case — for each number in the range we run is_prime which takes O(√b) time. Since the maximum value is b, the primality test dominates. Space: O(π(b) - π(a)) for the result list (the number of primes in the range), plus O(1) stack space.

    • Tripos 2025 Paper 1 Question 1 — Worked Solution

      Question: Identify five errors in a buggy merge sort implementation and suggest corrections. Then write check_sorted and checksort.

      Buggy code:

      let length = function
        | [] -> 0
        | _ :: t -> 1 + length t
      
      let rec merge l1 l2 =
        match l1, l2 with
        | [], l -> l
        | h1 :: t1, h2 :: t2 ->
            if h1 <= h2 then h1 :: merge t1 l2
            else h2 :: merge l1 t2
      
      let rec split l l1 l2 n =
        match l, n with
        | [], _ -> (l1, l2)
        | h :: t, 0 -> split t l1 (h :: l2) n
        | h :: t, _ -> split t (l1 :: h) l2 n
      
      let rec mergesort ls =
        match ls with
        | _ ->
            let n = length ls / 2 in
            let l, r = split ls [] [] n in
            merge (mergesort l) (mergesort r)
        | [] | [_] -> ls

      (a) Five errors and corrections [10 marks]

      Error 1: merge is not exhaustive — missing case ([], _::_). When l1 is [] and l2 is h2 :: t2, the first clause [], l matches and returns l — this is actually correct. But when l1 is h1 :: t1 and l2 is [], neither clause matches. The first clause requires l1 = [], and the second clause requires both l1 and l2 to be non-empty. The pattern h1 :: t1, h2 :: t2 is not matched when l2 = []. Fix: add a clause | l, [] -> l.

      Error 2: splitl1 :: h is a type error. The expression split t (l1 :: h) l2 n tries to cons h (an element) onto l1 (a list). But :: expects a list on the right: the element should be h :: l1, not l1 :: h. The parameters to split are (list, front_acc, back_acc, n), so we want h :: l1 to prepend to the front accumulator. Fix: split t (h :: l1) l2 (n-1).

      Error 3: split — when n = 0, elements go to l2 but n is not decremented. When n = 0, we prepend h to l2 and recurse with the same n (0). This puts all remaining elements into l2. But we want the first n elements in l1 and the rest in l2. When n = 0, we’ve put all the front elements — we should prepend remaining elements to l2. But the recursive call with n = 0 means split t l1 (h :: l2) 0, which will match h :: t, 0 again — this is actually correct for pushing all remaining elements to l2. However, the n in the non-zero case: Fix: split t (h :: l1) l2 (n-1) — we must decrement n when taking elements for the front list.

      Error 4: mergesort — clauses are in wrong order (wildcard _ before [] | [_]). The pattern _ matches everything, so the base cases [] | [_] are unreachable. The mergesort function will always try to split even singleton and empty lists, leading to infinite recursion or division by zero (length of empty list is 0, 0/2 = 0, and split [] [] [] 0 returns ([], []) which recurses forever). Fix: put the base cases first: match ls with | [] | [_] -> ls | _ -> ....

      Error 5: split — the n passed to recursive call is wrong when hitting n = 0. When we hit the h :: t, 0 case, we want to put h into l2 and then continue putting all remaining elements into l2. But we pass n (which is 0) unchanged. The recursive call will match h :: t, 0 again — this is fine, it will keep putting elements into l2. Actually, let me reconsider. The intent is: first n elements go to l1 (reversed), the rest go to l2 (reversed). When n = 0, all remaining go to l2. This works with n unchanged at 0. So this isn’t really a bug. But the real issue: when n > 0, we must pass n-1 to the recursive call so we take exactly n elements for the front. The original code passes n unchanged (the wildcard _ matches any n), meaning it never decrements n — it will try to put all elements into l1. Fix: split t (h :: l1) l2 (n-1).

      Corrected code:

      let rec merge l1 l2 =
        match l1, l2 with
        | [], l -> l
        | l, [] -> l
        | h1 :: t1, h2 :: t2 ->
            if h1 <= h2 then h1 :: merge t1 l2
            else h2 :: merge l1 t2
      
      let rec split l l1 l2 n =
        match l, n with
        | [], _ -> (l1, l2)
        | h :: t, 0 -> split t l1 (h :: l2) 0
        | h :: t, n -> split t (h :: l1) l2 (n - 1)
      
      let rec mergesort ls =
        match ls with
        | [] | [_] -> ls
        | _ ->
            let n = length ls / 2 in
            let l, r = split ls [] [] n in
            merge (mergesort l) (mergesort r)

      (b) check_sorted [4 marks]

      let rec check_sorted = function
        | [] -> true
        | [_] -> true
        | x :: (y :: _ as rest) ->
            x <= y && check_sorted rest

      Time complexity: O(n) where n is the length of the list. We visit each element once, comparing adjacent pairs. In the worst case (the list is sorted), we examine all n-1 adjacent pairs. Space complexity: O(n) stack space because the function is not tail-recursive — the && means the recursive call is nested. This could be made O(1) space with a tail-recursive version:

      let check_sorted xs =
        let rec go = function
          | [] | [_] -> true
          | x :: (y :: _ as rest) ->
              if x <= y then go rest else false
        in
        go xs

      The tail-recursive version ensures the compiler can optimise stack usage to O(1). It short-circuits on the first out-of-order pair.

      (c) checksort [6 marks]

      let checksort sorts input =
        let rec check = function
          | [] -> ()
          | sort :: rest ->
              let result = sort input in
              if check_sorted result then check rest
              else
                failwith "Sorting function produced unsorted output"
        in
        check sorts

      Explanation: checksort takes a list of sorting functions (all of type 'a list -> 'a list) and an input list. It applies each sorting function to the input and verifies that the result is sorted using check_sorted. If any function produces an unsorted output, it raises Failure with a descriptive message. If all pass, it returns (). The mechanism for signalling unexpected mismatches is OCaml’s built-in failwith which raises a Failure exception.

      Better version (with mismatch reporting):

      let checksort sorts input =
        let n = List.length sorts in
        let rec check i = function
          | [] -> ()
          | sort :: rest ->
              let result = sort input in
              if not (check_sorted result) then
                failwith (Printf.sprintf "Sort %d produced unsorted output" i)
              else if i > 0 then
                let first_result = (List.hd sorts) input in
                if result <> first_result then
                  failwith (Printf.sprintf "Sort %d produced different result from sort 0" i)
                else check (i + 1) rest
              else check (i + 1) rest
        in
        check 0 sorts

      Example usage:

      (* Assuming quicksort, bubblesort, and insertsort are defined elsewhere *)
      let sorts = [mergesort; quicksort; bubblesort; insertsort]
      let test_input = [5; 3; 8; 1; 9; 2; 7; 4; 6]
      checksort sorts test_input
      (* If all sort correctly, returns (). Otherwise raises Failure. *)

      Time complexity: For each of k sorting functions applied to an input of length n, the cost is k × T_s(n) where T_s(n) is the sorting time, plus k × O(n) for the check_sorted calls. Space: O(n) for each intermediate sorted list.

    • Tripos 2025 Paper 1 Question 2 — Worked Solution

      Question: Mathematical expressions and Polish notation.

      type expr =
        | Add of expr * expr
        | Mul of expr * expr
        | Number of int

      (a) OCaml value for (1+4)*(10+2) [2 marks]

      let e = Mul (Add (Number 1, Number 4), Add (Number 10, Number 2))

      The expression tree has Mul at the root, with left child Add (1, 4) and right child Add (10, 2). The Number constructor wraps each integer literal.

      (b) Evaluation function [4 marks]

      let rec eval = function
        | Number n -> n
        | Add (e1, e2) -> eval e1 + eval e2
        | Mul (e1, e2) -> eval e1 * eval e2

      Type: val eval : expr -> int

      Explanation: The function recursively evaluates the expression tree. Number n is already a value. For Add, we evaluate both subexpressions and add the results. For Mul, we multiply. The recursion is structural: each constructor case handles its subexpressions. Time complexity is O(n) where n is the number of nodes in the tree (each node visited once). Space complexity is O(d) where d is the tree depth (the recursion stack).

      For the example: eval (Mul (Add (Number 1, Number 4), Add (Number 10, Number 2))) = (1 + 4) * (10 + 2) = 5 * 12 = 60.

      (c) Type for Polish notation [2 marks]

      Polish notation (prefix notation) writes the operator before its operands, eliminating the need for parentheses. The expression (1+4)*(10+2) in Polish notation is represented as a flat list of tokens:

      Mul, Add, 1, 4, Add, 10, 2

      We need a type t that can represent operators and numbers in a single list:

      type t = Op of string | Num of int

      So the Polish notation for (1+4)*(10+2) is:

      let polish_expr : t list =
        [Op "*"; Op "+"; Num 1; Num 4; Op "+"; Num 10; Num 2]

      Explanation: The type t is a variant with two constructors: Op carries the operator as a string ("+" or "*"), and Num carries an integer. This unified type lets us represent both operators and operands in a single list, which is essential for Polish notation where operators and numbers are interleaved.

      (d) reduce — single-step reduction [8 marks]

      Write reduce that performs one step of reduction on a Polish notation list.

      The reduction rule for Polish notation: when an operator is followed by two numbers, replace the three tokens with the result of applying the operator.

      let reduce = function
        | Op "+" :: Num a :: Num b :: rest ->
            Num (a + b) :: rest
        | Op "*" :: Num a :: Num b :: rest ->
            Num (a * b) :: rest
        | expr -> expr

      Explanation: reduce looks at the head of the list. If it finds an operator followed by two numbers, it replaces those three tokens with the computed result. In all other cases (list too short, operator without two following numbers, number at the front), it returns the list unchanged — no reduction is possible.

      Tracing the example: Start with [Op "*"; Op "+"; Num 1; Num 4; Op "+"; Num 10; Num 2].

      First reduction: The outermost pattern Op "*" :: Num a :: Num b :: rest does not match because after Op "*" we have Op "+" not a Num. So we move to the next clause, which is the catch-all — wait, that would mean no reduction. But the reduction should happen at the innermost reducible expression.

      Actually, Polish notation reduction works from the inside out. The standard approach is to process the list looking for the first reducible pattern. Let’s reconsider.

      In Polish notation evaluation, we typically scan for the first occurrence of Op :: Num :: Num and reduce it. There are two approaches:

      Approach 1: Reduce the first reducible triple (leftmost innermost).

      let rec reduce = function
        | [] -> []
        | Op "+" :: Num a :: Num b :: rest ->
            Num (a + b) :: rest
        | Op "*" :: Num a :: Num b :: rest ->
            Num (a * b) :: rest
        | x :: rest -> x :: reduce rest

      This scans left to right, reducing the first reducible pattern it finds. For the example:

      • [Op "*"; Op "+"; Num 1; Num 4; Op "+"; Num 10; Num 2]
      • First pass: Op "*" followed by Op "+" — not a reducible triple. Recurse on the tail.
      • [Op "+"; Num 1; Num 4; Op "+"; Num 10; Num 2]
      • Op "+" :: Num 1 :: Num 4 :: rest — matches! Reduce to Num (1+4) = Num 5.
      • Result: Num 5 :: [Op "+"; Num 10; Num 2] — but wait, the result should be the full list: [Op "*"; Num 5; Op "+"; Num 10; Num 2].

      This approach is wrong because it returns early — it only returns Num 5 :: rest without the preceding Op "*". The issue is that reduce should return the entire list after one reduction, not just the reduced tail.

      Approach 2: Scan and reduce one step, preserving the full list.

      let rec reduce = function
        | [] -> []
        | Op "+" :: Num a :: Num b :: rest ->
            Num (a + b) :: rest
        | Op "*" :: Num a :: Num b :: rest ->
            Num (a * b) :: rest
        | x :: rest ->
            x :: reduce rest

      For the example [Op "*"; Op "+"; Num 1; Num 4; Op "+"; Num 10; Num 2]:

      • Not Op "+" at head, not Op "*" at head. Fall through: Op "*" :: reduce [Op "+"; Num 1; Num 4; Op "+"; Num 10; Num 2].
      • On [Op "+"; Num 1; Num 4; ...]: matches Op "+" :: Num 1 :: Num 4 :: restNum 5 :: [Op "+"; Num 10; Num 2].
      • So the full result after one pass: [Op "*"; Num 5; Op "+"; Num 10; Num 2].

      Second reduction applied to this result:

      • [Op "*"; Num 5; Op "+"; Num 10; Num 2]
      • Op "*" followed by Num 5 followed by Op "+" — not a reducible triple (second is Num but third is Op). Recurse.
      • [Num 5; Op "+"; Num 10; Num 2]Num 5 at head, not a reducible triple. Recurse.
      • [Op "+"; Num 10; Num 2] — matches! → Num 12 :: [].
      • Full result: [Op "*"; Num 5; Num 12].

      But wait — this is wrong for the second reduction. Let me trace more carefully.

      reduce [Op "*"; Num 5; Op "+"; Num 10; Num 2]:

      • Head is Op "*". Check: is Op "*" :: Num a :: Num b :: rest? Num 5 is Num a but Op "+" is not Num b. So fall through.
      • Op "*" :: reduce [Num 5; Op "+"; Num 10; Num 2]
      • reduce [Num 5; Op "+"; Num 10; Num 2]: head is Num 5, not an Op. Fall through.
      • Num 5 :: reduce [Op "+"; Num 10; Num 2]
      • reduce [Op "+"; Num 10; Num 2]: matches! Num (10+2) :: [] = [Num 12].
      • Going back: Num 5 :: [Num 12] = [Num 5; Num 12].
      • Then: Op "*" :: [Num 5; Num 12] = [Op "*"; Num 5; Num 12].

      This is correct! The second reduction correctly reduced the inner 10+2 to 12, producing [Op "*"; Num 5; Num 12].

      Third reduction: reduce [Op "*"; Num 5; Num 12] matches Op "*" :: Num 5 :: Num 12 :: []Num 60 :: [] = [Num 60].

      This reproduces the three steps shown in the question!

      (e) reduce_all [4 marks]

      let rec reduce_all expr =
        let reduced = reduce expr in
        if reduced = expr then
          match reduced with
          | [Num n] -> n
          | _ -> failwith "Cannot reduce further: not a single number"
        else
          reduce_all reduced

      Explanation: reduce_all repeatedly applies reduce until the expression cannot be simplified further. We compare the result of reduce with the input — if they are equal, no reduction was possible and we have reached a fixed point. At that point, we expect the list to contain exactly one Num n, which we extract. If the fixed point is anything else (e.g., an operator with insufficient operands), we raise an error.

      For the running example: reduce_all [Op "*"; Op "+"; Num 1; Num 4; Op "+"; Num 10; Num 2] reduces step by step to 60.

      Alternative (more elegant with pattern matching on the fixed point):

      let rec reduce_all = function
        | [Num n] -> n
        | expr ->
            let next = reduce expr in
            if next = expr then failwith "Stuck: cannot reduce"
            else reduce_all next

      Time complexity: Each reduce pass scans the list in O(n) time. In the worst case, we reduce one triple per pass, requiring O(n) passes for a deeply nested expression. Total: O(n^2) worst-case. Space: O(n) for the intermediate lists. A more efficient implementation would use a stack-based evaluator achieving O(n) time, but the question asks for step-by-step reduction demonstrating the process.