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 :: rest → Num 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.