diff --git a/docs/00-course-overview.md b/docs/00-course-overview.md index 41fc88d..61cf472 100644 --- a/docs/00-course-overview.md +++ b/docs/00-course-overview.md @@ -20,8 +20,8 @@ The course is suitable for a wide audience, from programming beginners to experi | 2 | [Development Environments & Expressions](./development-environments-expressions) | Pending | Pending | | | 3 | [Functions, Lists & Recursion](./functions-lists-recursion) | Pending | Pending | | | 4 | [Tuples, Structs & Enums](./tuples-structs-enums) | Pending | Pending | | -| 5 | Trees | Pending | Pending | | -| 6 | Generics & Higher-Order Functions | Pending | Pending | | +| 5 | [Trees](./trees) | Pending | Pending | | +| 6 | [Generics & Higher-Order Functions](./generics-higher-order-functions) | Pending | Pending | | | 7 | [Imperative Programming](./imperative-programming) | Pending | Pending | | | 8 | Queues | Pending | Pending | | | 9 | Traits | Pending | Pending | | diff --git a/docs/02-development-environments-expressions.md b/docs/02-development-environments-expressions.md index f83e3c4..1fd335b 100644 --- a/docs/02-development-environments-expressions.md +++ b/docs/02-development-environments-expressions.md @@ -131,6 +131,8 @@ In MoonBit, the type for Boolean values is `Bool`, and it can only have two poss In MoonBit, `==` represents a comparison between values. In the above examples, the left-hand side is an expression, and the right-hand side is the expected result. In other words, these examples themselves are expressions of type `Bool`, and we expect their values ​​to be `true`. +The `||` and `&&` operators are short-circuited. This means that if the outcome of the entire expression can be determined, the calculation will be halted, and the result will be immediately returned. For instance, in the case of `true || ...`, it is evident that `true || any value` will always yield true. Therefore, only the left side of the `||` operator needs to be evaluated. Similarly, when evaluating `false && ...`, since it is known that `false && any value` will always be false, the right side is not evaluated either. In this case, if the right side of the operator contains side effects, those side effects may not occur. + Quiz: How to define XOR (true if only one is true) using OR, AND, and NOT? #### Integers diff --git a/docs/03-functions-lists-recursion.md b/docs/03-functions-lists-recursion.md index 5139586..98e5267 100644 --- a/docs/03-functions-lists-recursion.md +++ b/docs/03-functions-lists-recursion.md @@ -165,6 +165,44 @@ For example: Since `->` is right-associative, in the last example, the brackets in the return type can be omitted. +### Labeled Arguments and Optional Arguments + +It is not uncommon to encounter difficulties recalling the order of parameters when calling a function, particularly when multiple parameters share the same type. In such situations, referring to documentation or IDE prompts can be helpful. However, when reviewing code written by others, these resources may not be readily available. To overcome this challenge, labeled arguments offer a practical solution. In MoonBit, we can make a parameter "labeled" by prefixing it with `~`. For instance, consider the following code snippet: + +```moonbit +fn greeting1(~name: String, ~location: String) -> Unit { + println("Hi, \(name) from \(location)!") +} + +fn init { + greeting1(~name="somebody", ~location="some city") + let name = "someone else" + let location = "another city" + // `~label=label` can be abbreviated as `~label` + greeting1(~name, ~location) +} +``` + +By using labeled arguments, the order of the parameters becomes less important. In addition, they can be made optional by specifying a default value when declaring them. When the function is called, if no argument is explicitly provided, the default value will be used. + +Consider the following example: + +```moonbit +fn greeting2(~name: String, ~location: Option[String] = None) -> Unit { + match location { + Some(location) => println("Hi, \(name)!") + None => println("Hi, \(name) from \(location)!") + } +} + +fn init { + greeting2(~name="A") // Hi, A! + greeting2(~name="B", ~location=Some("X")) // Hi, B from X! +} +``` + +It is important to note that the default value expression will be evaluated each time the function is called. + ## Lists Data is everywhere. Sometimes, we have data with the following characteristics: diff --git a/docs/04-tuples-structs-enums.md b/docs/04-tuples-structs-enums.md index 5da852f..5d4b285 100644 --- a/docs/04-tuples-structs-enums.md +++ b/docs/04-tuples-structs-enums.md @@ -236,7 +236,7 @@ Additionally, enumerated types prevent the representation of irrational data. Fo Each variant of an enumerated type can also carry data. For instance, we've seen the enumerated type `Option`. -```moonbit +```moonbit no-check enum Option[T] { Some(T) None @@ -251,6 +251,35 @@ enum ComputeResult { To do this, simply enclose parameters with parentheses and separate them by commas after each variant. In the second example, we define the case of successful integer operation, and the value is an integer. Enumerated types correspond to a distinguishable union. What does that mean? First, it is a union of different cases, for example, the set represented by the type `T` for `Some` and the set defined by the singular value `None`. Second, this union is distinguishable because each case has a unique name. Even if there are two cases with the same data type, they are entirely different. Thus, enumerated types are also known as sum types. +### Labeled Arguments + +Similar to functions, enum constructors also support the use of labeled arguments. This feature is beneficial in simplifying pattern matching patterns. For example: + +```moonbit +enum Tree[X] { + Nil + Branch(X, ~left : Tree[X], ~right : Tree[X]) +} + +fn leftmost[X](self : Tree[X]) -> Option[X] { + loop self { + Nil => None + // use `label=pattern` to match labeled arguments of constructor + Branch(top, left=Nil, right=Nil) => Some(top) + // `label=label` can be abbreviated as `~label` + Branch(_, left=Nil, ~right) => continue right + // use `..` to ignore all remaining labeled arguments + Branch(_, ~left, ..) => continue left + } +} + +fn init { + // syntax for creating constructor with labeled arguments is the same as calling labeled function + let t: Tree[Int] = Branch(0, right=Nil, left=Branch(1, left=Nil, right=Nil)) + println(t.leftmost()) // `Some(1)` +} +``` + ## Algebraic Data Types We've mentioned product types and sum types. Now, let me briefly introduce algebraic data types. It's important to note that this introduction to algebraic data types is quite basic. Please read the references for a deeper understanding. diff --git a/docs/05-trees.md b/docs/05-trees.md new file mode 100644 index 0000000..198e8ca --- /dev/null +++ b/docs/05-trees.md @@ -0,0 +1,349 @@ +# 5. Trees + +In this chapter, we explore a common data structure: trees, and related algorithms. We will start with a simple tree, understand the concept, and then learn about a specialized tree: binary tree. After that, we will explore a specialized binary tree: binary search tree. Further, we will also learn about the balanced binary tree. + +Trees are very common plants in our lives, as shown in the diagram. + +![trees](/pics/trees.drawio.webp) + +A tree has a root with multiple branches, each branch having leaves or other small branches. In fact, many data structures in our everyday lives look like a tree. For example, the pedigree chart, a.k.a., a family tree, grows from a pair of ancestors. We also use the phrase "branching out" to describe this process. Another example is file structure, where a folder may contain some files and other folders, just like leaves and branches. Mathematical expressions can also be represented as a tree, where each node is an operator, and each leaf is a number, with operators closer to the root being computed later. + +## Trees + +In data structures, a tree is a finite collection of nodes that have a hierarchical relationship. Each node is a structure that stores data. It is common to describe this hierarch using family relationships, like parents, children, descendants and ancestors. Sometimes, we say there is a parent-child relationship between adjacent nodes, calling them parent nodes and child nodes. A node's descendants are all the nodes that stem from a node, while a node’s ancestors are all the nodes that it stems from. + +If a tree is not empty, it should have exactly one root node, which has only child nodes and no parent nodes. All nodes except the root node should have exact one parent node. Nodes without child nodes, which are the outermost layer of nodes, are called leaf nodes, akin to the leaves of a tree. Additionally, no node can be its own descendant, meaning cycles cannot exist within the tree. + +![](/pics/abstract-tree-en.drawio.webp) + +In a tree, an edge refers to a pair of nodes $(u, v)$, where either $u$ is the parent node of $v$ or $v$ is the parent node of $u$; simply put, these two nodes should have a parent-child relationship. We use arrows in diagrams to indicate parent-child relationships, with the arrow pointing from an ancestor to its descendant. + +The example below is not a tree. + +![](/pics/not-a-tree-en.drawio.webp) + +Each red mark violates the requirements of a tree. In the upper right, there's another root node without a parent node, implying two root nodes in a tree, which is not allowed. At the bottom, the left leaf node has an extra arrow pointing to the root node, implying it's the parent node of the root, violating the structure requirements. And the right leaf node has two parent nodes, which also doesn't comply with the requirements. + +It's common to place the root node at the top, with child nodes arranged below their parent nodes. We have some terms related to trees. Firstly, the depth of a node corresponds to the length of the path from the root node down to that node. In other words, the number of edges traversed when going from the root node downwards. Therefore, the depth of the root is $0$. Then the height of a node corresponds to the length of the longest path from the node to a leaf node. Likewise, the height of a leaf node is $0$. Finally, there's the height of a tree, which is equivalent to the height of the root node. If a tree has only one node, it is both the root node and a leaf node, with a height of $0$. If a tree is empty, meaning it has no nodes, we define its height as $-1$. However, some books may define it differently, considering the layers of the tree, with the root being the first layer, and so on. + +Having discussed the logical structure of a tree, let's now consider its storage structure. While the logical structure defines the relationships between data, the storage structure defines the specific representation of data. We'll use a binary tree as an example, where each node has at most two children. Here, we'll represent the tree using a list of tuples. Each tuple defines a parent-child relationship, such as `(0, 1)`, indicating that node $0$ is the parent of node $1$. + +Another way is to use algebraic data structures we have talked about previously: + +```moonbit no-check +Node(0, + Node(1, + Leaf(3), + Empty), + Leaf(2)) +``` + +We define several cases using an enumeration type: `Node` represents a regular tree node with its own number and two subtrees, `Leaf` represents a tree with only one node, i.e., a leaf node, having only its own number, and `Empty` represents an empty tree. With this representation, we can define a tree structure similar to before. Of course, this is just one possible implementation. + +The final approach is a list where each level's structure is arranged consecutively from left to right: + +![](/pics/list-tree.drawio.webp) + +For example, the root node is placed at the beginning of the list, followed by nodes of the second level from left to right, then nodes of the third level from left to right, and so on. Thus, node $3$ and the node to its right are children of node $1$, while the two nodes after are children of $2$. These three nodes are all empty in the example. + +We can see that all three methods define the same tree, but their storage structures are quite different. Hence, we can conclude that the logical structure of data is independent of its storage structure. + +Finally, the tree data structure has many derivatives. For example, a segment tree stores intervals and corresponding data, making it suitable for one-dimensional queries. Binary trees are a special type where each node has at most two branches, namely the left subtree and the right subtree. B-trees are suitable for sequential access, facilitating the storage of data on disks. KD-trees and R-trees, which are derivatives of binary trees and B-trees respectively, are suitable for storing spatial data structures. Apart from these, there are many other tree structures. + +## Binary Trees + +A binary tree is either empty or consist of nodes that have at most two subtrees: a left subtree and a right subtree. For example, both subtrees of a leaf node are empty. Here, we adopt a definition based on recursive enumeration types, with default data storage being integers. + +```moonbit +enum IntTree { + Node(Int, IntTree, IntTree) // data, left subtree, right subtree + Empty +} +``` + +The first algorithm we will discuss is binary tree traversal (or search). Tree traversal refers to the process of visiting all nodes of a tree in a certain order without repetition. Typically, there are two methods of traversal: depth-first and breadth-first. Depth-first traversal always visits one subtree before the other. During the traversal of a subtree, it recursively visits one of its subtrees. Thus, it always reaches the deepest nodes first before returning. For example, + +![](/pics/traversal-en.drawio.webp) + +In the diagram, we first visit the left subtree, then the left subtree again, leading to the visit of $3$. Subsequently, we continuously visit the right subtree, resulting in the visit of $5$. Finally, we visit the right subtree of the entire tree, which is $2$. On the other hand, breadth-first traversal starts from the root node and proceeds layer by layer, visiting nodes at a certain depth before moving deeper. For the same tree, breadth-first traversal will visit the root node first, followed by subtrees $1$ and $2$, then $3$ and $4$, and finally the deepest node $5$. + +Depth-first traversal usually involves three variations: preorder traversal, inorder traversal, and postorder traversal. The difference lies in when the root node is visited while traversing the entire tree. For example, in preorder traversal, the root node is visited first, followed by the left subtree, and then the right subtree. Taking the tree we just saw as an example, this means we start with $0$, then visit the left subtree; when visiting the left subtree, we start from the root node again, which is $1$; then $3$, $4$, $5$, and $2$. In inorder traversal, the left subtree is visited first, followed by the root node, and then the right subtree. Hence, it first visits the left subtree. At this moment, there is still a left subtree, so we go down to tree $3$. Now, it's a leaf node without a left subtree, so we visit the root node $3$. Then we return to visit the root node $1$ of the subtree and proceed to visit the right subtree. Postorder traversal follows a similar logic, visiting the left subtree first, then the right subtree, and finally the root node. In fact, solving the Fibonacci sequence can be seen as a postorder traversal, as we first visit the $(n-1)$-th and $(n-2)$-th items, which are two subtrees, and then solve the $n$-th item, which is the value of the root node. As for breadth-first traversal, we have already explained it: from left to right, the order is `[0, 1, 2, 3, 4, 5]`. + +Let's take a look at the specific implementation of these two traversals in terms of finding a specific value in the tree's nodes. Firstly, let's consider depth-first traversal. + +```moonbit +fn dfs_search(target: Int, tree: IntTree) -> Bool { + match tree { // check the visited tree + Empty => false // empty tree implies we are getting deepest + Node(value, left, right) => // otherwise, search in subtrees + value == target || dfs_search(target, left) || dfs_search(target, right) + } +} +``` + +As we introduced earlier, this is a traversal based on structural recursion. We first handle the base case, i.e., when the tree is empty, as shown in the third line. In this case, we haven't found the value we're looking for, so we return `false`. Then, we handle the recursive case. For a node, we check if its value is the desired result, as shown in line 5. If we find it, the result is `true`. Otherwise, we continue to traverse the left and right subtrees alternately, if either we find it in left subtree or right subtree will the result be `true`. In the current binary tree, we need to traverse both the left and right subtrees to find the given value. The binary search tree introduced later will optimize this process. The only differences between preorder, inorder, and postorder searches is the order of operations on the current node, the left subtree search, and the right subtree search. + +### Queues + +Now let's continue with breadth-first traversal. + +![](/pics/bfs-en.drawio.webp) + +As mentioned earlier, breadth-first traversal involves visiting each subtree layer by layer. In this case, to be able to record all the trees we are going to visit, we need a brand-new data structure: the queue. + +![](/pics/queue-en.drawio.webp) + +A queue is a first-in-first-out (FIFO) data structure. Each time, we dequeue a tree from the queue and check whether its node value is the one we're searching for. If not, we enqueue its non-empty subtrees from left to right and continue the computation until the queue is empty. + +Let's take a closer look. Just like lining up in real life, the person who enters the line first gets served first, so it's important to maintain the order of arrival. The insertion and deletion of data follow the same order, as shown in the diagram. We've added numbers from $0$ to $5$ in order. After adding $6$, it follows $5$; and if we delete from the queue, we start from the earliest added $0$. + +The queue we're using here is defined by the following interface: + +```moonbit no-check +fn empty[T]() -> Queue[T] // construct an empty queue +fn enqueue[T](q: Queue[T], x: T) -> Queue[T] // add element to the tail +// attempt to dequeue an element, return None if the queue is empty +fn pop[T](q: Queue[T]) -> (Option[T], Queue[T]) +``` + + + +`empty`: construct an empty queue; `enqueue`: add an element to the queue, i.e., add it to the tail; `pop`: attempt to dequeue an element and return the remaining queue. If the queue is already empty, the returned value will be `None` along with an empty queue. For example, + +```moonbit no-check +let q = enqueue(enqueue(empty(), 1), 2) +let (head, tail) = pop(q) +assert(head == Some(1)) +assert(tail == enqueue(empty(), 2)) +``` + +We've added $1$ and $2$ to an empty queue. Then, when we try to dequeue an element, we should get `Some(1)`, and what's left should be equivalent to adding $2$ to an empty queue. + +Let's return to the implementation of our breadth-first traversal. + +```moonbit +fn bfs_search(target: Int, queue: Queue[IntTree]) -> Bool { + match pop(queue) { + (None, _) => false // If the queue is empty, end the search + (Some(head), tail) => match head { // If the queue is not empty, operate on the extracted tree + Empty => bfs_search(target, tail) // If the tree is empty, operate on the remaining queue + Node(value, left, right) => + if value == target { true } else { + // Otherwise, operate on the root node and add the subtrees to the queue + bfs_search(target, enqueue(enqueue(tail, left), right)) + } + } + } +} +``` + +If we want to search for a specific value in the tree's nodes, we need to maintain a queue containing trees, as indicated by the parameters. Then, we check whether the current queue is empty. By performing a `pop` operation and pattern matching on the head of the queue, if it's empty, we've completed all the searches without finding the value, so we return `false`. If the queue still has elements, we operate on this tree. If the tree is empty, we directly operate on the remaining queue; if the tree is not empty, as before, we check if the current node is the value we're looking for. If it is, we return `true`; otherwise, we enqueue the left and right subtrees `left` and `right` into the queue and continue searching the queue. + +So far, we've concluded our introduction to tree traversal. However, we may notice that this type of search doesn't seem very efficient because every subtree may contain the value we're looking for. Is there a way to reduce the number of searches? The answer lies in the binary search tree, which we'll introduce next. + +## Binary Search Trees + +Previously, we mentioned that searching for elements in a binary tree might require traversing the entire tree. For example, in the diagram below, we attempt to find the element $8$ in the tree. + +![](/pics/bst-en.drawio.webp) + +For the left binary tree, we have to search the entire tree, ultimately concluding that $8$ is not in the tree. + +To facilitate searching, we impose a rule on the arrangement of data in the tree based on the binary tree: from left to right, the data is arranged in ascending order. This gives rise to the binary search tree. According to the rule, all data in the left subtree should be less than the node's data, and the node's data should be less than the data in the right subtree, as shown in the diagram on the right. + +We notice that if we perform an inorder traversal, we can traverse the sorted data from smallest to largest. Searching on a binary search tree is very simple: determine whether the current value is less than, equal to, or greater than the value we are looking for, then we know which subtree should be searched further. In the example above, when we check if $8$ is in the tree, we find that $8$ is greater than $5$, so we should search on the right subtree next. When we encounter $7$, we find that there is no right subtree, meaning there are no numbers greater than $7$, so we conclude that $8$ is not in the tree. As you can see, our search efficiency has greatly improved. In fact, the maximum number of searches we need to perform, in the worst-case scenario, is the height of the tree plus one, rather than the total number of elements. In some cases, the height of the tree may also equal the total number of elements, as we will see later. + +To maintain such a tree, we need special algorithms for insertion and deletion to ensure that the modified tree still maintains its order. Let's explore these two algorithms. + +![](/pics/bst-insertion.drawio.webp) + +For the insertion on a binary search tree, we also use structural recursion. First, we discuss the base case where the tree is empty. In this case, we simply replace the tree with a new tree containing only one node with the value we want to insert. Next, we discuss the recursive case. If a tree is non-empty, we compare the value we want to insert with the node's value. If it's less than the node, we insert the value into the left subtree and replace the left subtree with the subtree after insertion. If it's greater than the node, we replace the right subtree. For example, if we want to insert $3$, we need to compare it with each node. For instance, if it's less than $5$, we operate on the left subtree. Later, if it's greater than $2$, we operate on the right subtree. We focus on this subtree. As we can see, since $3$ is less than $4$, we should insert it into the left subtree. Then since this is an empty tree, we construct a tree containing only one node and replace the left subtree with a tree only containing $4$. + +```moonbit +fn insert(tree: IntTree, value: Int) -> IntTree { + match tree { + Empty => Node(value, Empty, Empty) // construct a new tree if it's empty + Node(v, left, right) => // if not empty, update one subtree by insertion + if value == v { tree } else + if value < v { Node(v, insert(left, value), right) } else + { Node(v, left, insert(right, value)) } + } +} +``` + +Here we can see the complete insertion code. In line 3, if the original tree is empty, we reconstruct a new tree. In lines 6 and 7, if we need to update the subtree, we use the `Node` constructor to build a new tree based on the updated subtree. + +Next, we discuss the delete operation. + +![](/pics/bst-deletion-en.drawio.webp) + +Similarly, we do it with structural recursion. If the tree is empty, it's straightforward because we don't need to do anything. If the tree is non-empty, we need to compare it with the current value and determine whether the current value needs to be deleted. If it needs to be deleted, we delete it. We'll discuss how to delete it later; if it's not the value we want to delete, we still need to compare it and find the subtree where the value might exist, then create an updated tree after deletion. The most crucial part of this process is how to delete the root node of a tree. If the tree has no subtrees or only one subtree, it's straightforward because we just need to replace it with an empty tree or the only subtree. The trickier part is when there are two subtrees. In this case, we need to find a new value to be the root node, and this value needs to be greater than all values in the left subtree and less than all values in the right subtree. There are two values that satisfy this condition: the maximum value in the left subtree and the minimum value in the right subtree. Here, we use the maximum value in the left subtree as an example. Let's take another look at the schematic diagram. If there are no subtrees, we simply replace it with an empty tree; if there's one subtree, we replace it with the subtree. If there are two subtrees, we need to set the maximum value in the left subtree as the value of the new root node and delete this value from the left subtree. The good news is that this value has at most one subtree, so the operation is relatively simple. + +```moonbit +fn remove_largest(tree: IntTree) -> (IntTree, Int) { + match tree { + Node(v, left, Empty) => (left, v) + Node(v, left, right) => { + let (newRight, value) = remove_largest(right) + (Node(v, left, newRight), value) + } } +} +``` + +```moonbit no-check +fn remove(tree: IntTree, value: Int) -> IntTree { + match tree { ... + Node(root, left, right) => if root == value { + let (newLeft, newRoot) => remove_largest(left) + Node(newRoot, newLeft, right) + } else ... } +} +``` + +Here, we demonstrate part of the deletion of a binary search tree. We define a helper function to find and delete the maximum value from the left subtree. We keep traversing to the right until we reach a node with no right subtree because having no right subtree means there are no values greater than it. Based on this, we define the deletion, where when both subtrees are non-empty, we can use this helper function to obtain the new left subtree and the new root node. We omit the specific code implementation here, leaving it as an exercise for you to practice after class. + +### Balanced Binary Trees + +Finally, we delve into the balanced binary trees. When explaining binary search trees, we mentioned that the worst-case number of searches in a binary search tree depends on the height of the tree. Insertion and deletion on a binary search tree may cause the tree to become unbalanced, meaning that one subtree's height is significantly greater than the other's. For example, if we insert elements from $1$ to $5$ in sequence, we'll get a tree as shown in the lower left diagram. + +![](/pics/worst-bst-en.drawio.webp) + +We can see that for the entire tree, the height of the left subtree is $-1$ because it's an empty tree, while the height of the right subtree is $3$. In this case, the worst-case number of searches equals the number of elements in the tree, which is $5$. However, if the tree is more balanced, meaning the heights of the two subtrees are similar, as shown in the right diagram, the maximum depth of a node is at most $2$, which is approximately $\log_2n$ times, where $n$ is the number of elements in the tree. As you may recall from the curve of the logarithmic function, when the number of elements in the tree is large, there can be a significant difference in the worst-case search time between the two scenarios. Therefore, we hope to avoid this worst-case scenario to ensure that we always have good query performance. To achieve this, we can introduce a class of data structures called balanced binary trees, where the heights of any node's left and right subtrees are approximately equal. Common types of balanced binary trees include AVL trees, 2-3 trees, or red-black trees. Here, we'll discuss AVL trees, which are relatively simple. + +The key to maintaining balance in a binary balanced tree is that when the tree becomes unbalanced, we can rearrange the tree to regain balance. The insertion and deletion of AVL trees are similar to standard binary search trees, except that AVL trees perform adjustments after each insertion or deletion to ensure the tree remains balanced. We add a height attribute to the node definition. + +```moonbit no-check +enum AVLTree { + Empty + // current value, left subtree, right subtree, height + Node(Int, ~left: AVLTree, ~right: AVLTree, ~height: Int) +} +fn create(value : Int, ~left : AVLTree = Empty, ~right : AVLTree = Empty) -> AVLTree +fn height(tree: AVLTree) -> Int +``` + +Here, we used the syntax for labeled arguments, which we introduced in [Chapter 3](./functions-lists-recursion#labeled-argument-and-optional-arguments) and [Chapter 4](./tuples-structs-enums#labeled-arguments). The `create` function creates a new AVL tree whose both subtrees are empty by default, without explicitly maintaining its height. Since the insertion and deletion operations of AVL trees are similar to standard binary search trees, we won't go into detail here. + +![](/pics/rotation.drawio.webp) + +After inserting or deleting an element, we traverse back from the modified location until we find the first unbalanced position, which we call $z$. Then, we use $y$ to represent the higher subtree of $z$, and $x$ is the higher subtree of $y$. Next, we discuss rebalancing. In the first case, $x$ is between $y$ and $z$. In this case, we can move $x$ above its parent and grandparent. We can see that the depths of $x$'s two subtrees decrease by $1$, thereby reducing the height of the entire tree. Although the depth of $T_4$ increases by $1$, it was originally lower than the left subtree, so it remains balanced. The other case is when $x$ is on $y$ and $z$'s same side. In this case, we can reduce the height of the tree by making $y$ the root node. The purpose is still to reduce the depths of the two deepest subtrees to decrease the height of the left subtree. + +```moonbit no-check +fn balance(left: AVLTree, z: Int, right: AVLTree) -> AVLTree { + if height(left) > height(right) + 1 { + match left { + Node(y, left_l, left_r, _) => + if height(left_l) >= height(left_r) { + create(left_l, y, create(lr, z, right)) // x is on y and z's same side + } else { match left_r { + Node(x, left_right_l, left_right_r, _) => // x is between y and z + create(create(left_l, y, left_right_l), x, create(left_right_r, z, right)) + } } + } + } else { ... } +} +``` + +Here is a snippet of code for a balanced tree. You can easily complete the code once you understand what we just discussed. We first determine if a tree is unbalanced, by checking if the height difference between the two subtrees exceeds a specific value and which side is higher. After determining this, we perform a rebalancing operation. At this point, the root node we pass in is $z$, and the higher side after pattern matching is $y$. Then, based on the comparison of the heights of $y$'s two subtrees, we further determine whether $x$ is $y$ and $z$'s same side or between $y$ and $z$, as shown in line 6. Afterwards, we recombine based on the scenarios discussed earlier, as shown in lines 6 and 9. Taking insertion of an element as an example: + +```moonbit no-check +fn add(tree: AVLTree, value: Int) -> AVLTree { + match tree { + // When encountering the pattern `Node(v, ..) as t`, + // the compiler will know that `t` must be constructed by `Node`, + // so `t.left` and `t.right` can be directly accessed within the branch. + Node(v, ..) as t => { + if value < v { balance(add(t.left, value), v, t.right) } else { ... } + } + Empty => ... + } +} +``` + +After inserting the element, we directly perform a rebalancing operation on the resulting tree. + +## Summary + +In this chapter, we introduced the trees, including the definition of a tree and its related terminology, the definition of a binary tree, traversal algorithms, the definition of a binary search tree, its insertion and deletion operations, as well as the rebalancing operation of AVL trees, a type of balanced binary tree. For further study, please refer to: + +- _**Introduction to Algorithms**_: Chapter 12 - Binary Search Trees; and +- _**Introduction to Algorithms**_: Chapter 13 - Red-Black Trees diff --git a/docs/06-generics-higher-order-functions.md b/docs/06-generics-higher-order-functions.md new file mode 100644 index 0000000..652ac0d --- /dev/null +++ b/docs/06-generics-higher-order-functions.md @@ -0,0 +1,362 @@ +# 6. Generics & Higher-Order Functions + +In development, we often encounter similar data structures and similar operations. At such times, we can reuse this information through good abstraction, which not only ensures maintainability but also allows us to ignore some details. Good abstraction should follow two principles: first, it represents same patterns or structures that appear repeatedly in the code; second, it has appropriate semantics. For example, we might need to perform the sum operation on lists of integers on many occasions, hence the repetition. Since summing has appropriate semantics, it is suitable for abstraction. We abstract this operation into a function and then use the function repeatedly, instead of writing the same code. + +Programming languages provide us with various means of abstraction, such as functions, generics, higher-order functions, interfaces, etc. This chapter will introduce generics and higher-order functions, and the next chapter will discuss interfaces. + +## Generic Functions and Generic Data + +Let's first look at the stack data structure to understand why and how we use generics. + +A stack is a collection composed of a series of objects, where the insertion and removal of these objects follow the Last-In-First-Out (LIFO) principle. For example, consider the containers stacked on a ship as shown in the left-hand image below. + +![](/pics/stack-objects.drawio.webp) + +Clearly, new containers are stacked on top, and when removing containers, those on top are removed first, meaning the last placed container is the first to be removed. Similarly, with a pile of stones in the right-hand image, if you don’t want to topple the pile, you can only add stones at the top or remove the most recently added stones. This structure is a stack. There are many such examples in our daily lives, but we will not enumerate them all here. + +For a data type stack, we can define operations as follows. Taking an integer stack `IntStack` as an example, we can create a new empty stack; we can add an integer to the stack; we can try to remove an element from the stack, which may not exist because the stack could be empty, hence we use an Option to wrap it. + +```moonbit no-check +empty: () -> IntStack // create a new stack +push : (Int, IntStack) -> IntStack // add a new element to the top of the stack +pop: IntStack -> (Option[Int], IntStack) // remove an element from the stack +``` + +As shown in the diagram below, we add a 2 and then remove a 2. We simply implement this definition of a stack. + +![](/pics/stack-push-pop-en.drawio.webp) + +```moonbit +enum IntStack { + Empty + NonEmpty(Int, IntStack) +} +fn IntStack::empty() -> IntStack { Empty } +fn push(self: IntStack, value: Int) -> IntStack { NonEmpty(value, self) } +fn pop(self: IntStack) -> (Option[Int], IntStack) { + match self { + Empty => (None, Empty) + NonEmpty(top, rest) => (Some(top), rest) + } +} +``` + +In the code snippet, we see that we set the first argument as `IntStack`, and the variable name is `self`, allowing us to chain function calls. This means we can write `IntStack::empty().push(2).pop()` instead of `pop(push(2, IntStack::empty()))`. The deeper meaning of this syntax will be explained in the next chapter. + +Returning to our code, we defined a recursive data structure based on stack operations: a stack may be empty or may consist of an element and a stack. Creating a stack is to build an empty one. Adding an element builds a non-empty stack with the top element being the one we want to add, while the stack underneath remains as it was. Removing from the stack requires pattern matching, where if the stack is empty, there are no values to retrieve; if the stack is not empty, the top element can be taken. + +The definition of a stack is very similar to that of a list. In fact, in MoonBit built-in library, lists are essentially stacks. + +After defining a stack for integers, we might also want to define stacks for other types, such as a stack of strings. This is simple, and we only demonstrate the code here without explanation. + +```moonbit +enum StringStack { + Empty + NonEmpty(String, StringStack) +} +fn StringStack::empty() -> StringStack { Empty } +fn push(self: StringStack, value: String) -> StringStack { NonEmpty(value, self) } +fn pop(self: StringStack) -> (Option[String], StringStack) { + match self { + Empty => (None, Empty) + NonEmpty(top, rest) => (Some(top), rest) + } +} +``` + +Indeed, the stack of strings looks exactly like the stack of integers, except for some differences in type definitions. But if we want to add more data types, should we redefine a stack data structure for each type? Clearly, this is unacceptable. + +### Generics in MoonBit + +Therefore, MoonBit provides an important language feature: generics. Generics are about taking types as parameters, allowing us to define more abstract and reusable data structures and functions. For example, with our stack, we can add a type parameter `T` after the name to indicate the actual data type stored. + +```moonbit +enum Stack[T] { + Empty + NonEmpty(T, Stack[T]) +} +fn Stack::empty[T]() -> Stack[T] { Empty } +fn push[T](self: Stack[T], value: T) -> Stack[T] { NonEmpty(value, self) } +fn pop[T](self: Stack[T]) -> (Option[T], Stack[T]) { + match self { + Empty => (None, Empty) + NonEmpty(top, rest) => (Some(top), rest) + } +} +``` + +Similarly, the functions defined later also have a `T` as a type parameter, representing the data type stored in the stack we operate on and the type of data we want to add. We only need to replace the identifier with a parameter, replacing `T` with a specific type, to obtain the actual data structures and functions. For example, if `T` is replaced with `Int`, then we obtain the previously defined `IntStack`. + +### Example: Generic Pair + +We have already introduced the syntax, and we have more examples. + +```moonbit +struct Pair[A, B]{ first: A; second: B } +fn identity[A](value: A) -> A { value } +``` + +For example, we can define a pair of data, or a tuple. The pair has two type parameters because we might have two elements of two different types. The stored values `first` and `second` are respectively of these two types. As another example, we define a function `identity` that can operate on any type and always return the input value. + +`Stack` and `Pair` can themselves be considered as functions on types, with their parameters being `T` or `A, B`, and the results of the operation are specific types like `Stack[T]` and `Pair[A, B]`. `Stack` and `Pair` can be regarded as type constructors. In most cases, the type parameters in MoonBit can be inferred based on the specific parameter types. + +![](/pics/polymorphism-type.webp) + +For example, in the screenshot here, the type of `empty` is initially unknown. But after `push(1)`, we understand that it is used to hold integers, thus we can infer that the type parameters for `push` and `empty` should be integer `Int`. + +### Example: Generic Functional Queue + +Now let's look at another generic data structure: the queue. We have already used the queue in the breadth-first sorting in the last lesson. Recall, a queue is a First-In-First-Out data structure, just like we queue up in everyday life. Here we define the following operations, where the queue is called `Queue`, and it has a type parameter. + +```moonbit no-check +fn empty[T]() -> Queue[T] // Create an empty queue +fn push[T](q: Queue[T], x: T) -> Queue[T] // Add an element to the tail of the queue +// Try to dequeue an element and return the remaining queue; if empty, return itself +fn pop[T](q: Queue[T]) -> (Option[T], Queue[T]) +``` + +Every operation has a type parameter, indicating the type of data it holds. We define three operations similar to those of a stack. The difference is that when removing elements, the element that was first added to the queue will be removed. + +The implementation of the queue can be simulated by a list or a stack. We add elements at the end of the list, i.e., at the bottom of the stack, and take them from the front of the list, i.e., the top of the stack. The removal operation is very quick because it only requires one pattern matching. But adding elements requires rebuilding the entire list or stack. + +```moonbit no-check +Cons(1, Cons(2, Nil)) => Cons(1, Cons(2, Cons(3, Nil))) +``` + +As shown here, to add an element at the end, i.e., to replace `Nil` with `Cons(3, Nil)`, we need to replace the whole `Cons(2, Nil)` with `Cons(2, Cons(3, Nil))`. And worse, the next step is to replace the `[2]` occurred as tail in the original list with `[2, 3]`, which means to rebuild the entire list from scratch. It is very inefficient. + +To solve this problem, we use two stacks to simulate a queue. + +```moonbit no-check +struct Queue[T] { + front: Stack[T] // For removing elements + back: Stack[T] // For storing elements +} +``` + +One stack is for the removal operation, and the other for storage. In the definition, both types are `Stack[T]`, and `T` is the queue's type parameter. When adding data, we directly store it in `back`: this step is quick because it builds a new structure on top of the original one; the removal operation also only needs one pattern matching, which is not slow either. When all elements in `front` have been removed, we need to rotate all elements from `back` into `front`. We check this after each operation to ensure that as long as the queue is not empty, then `front` is not empty. This checking is the invariant of our queue operations, a condition that must hold. This rotation is very costly, proportional to the length of the list at that time, but the good news is that this cost can be amortized, because after a rotation, the following several removal operations no longer need rotation. + +![](/pics/queue_push.drawio.webp) + +![](/pics/queue_push_more.drawio.webp) + +![](/pics/queue_pop.drawio.webp) + +Let's look at a specific example. Initially, we have an empty queue, so both stacks are empty. After one addition, we add a number to `back`. Then we organize the queue and find that the queue is not empty, but `front` is empty, which does not meet our previously stated invariant, so we rotate the stack `back` and move rotated elements to `front`. Afterwards, we continue to add elements to `back`. Since `front` is not empty, it meets the invariant, and we do not need additional processing. + +After that, our repeatedly additions are only the quick addition of new elements in `back`. Then, we remove elements from `front`. We check the invariant after the operation. We find that the queue is not empty, but `front` is empty, so we do retate `back` and move elements to `front` again. After that, we can normally take elements from `front`. + +You can see that one rotation supports multiple removal operations, therefore the overall cost is much less than rebuilding the list every time. + +```moonbit +struct Queue[T] { + front: Stack[T] + back: Stack[T] +} +fn Queue::empty[T]() -> Queue[T] { {front: Empty, back: Empty} } + +// Store element at the end of the queue +fn push[T](self: Queue[T], value: T) -> Queue[T] { + normalize({ ..self, back: self.back.push(value)}) // By defining the first argument as self, we can use xxx.f() +} + +// Remove the first element +fn pop[T](self: Queue[T]) -> (Option[T], Queue[T]) { + match self.front { + Empty => (None, self) + NonEmpty(top, rest) => (Some(top), normalize({ ..self, front: rest})) + } +} + +// If front is empty, reverse back to front +fn normalize[T](self: Queue[T]) -> Queue[T] { + match self.front { + Empty => { front: self.back.reverse(), back: Empty } + _ => self + } +} + +// Helper function: reverse the stack +fn reverse[T](self: Stack[T]) -> Stack[T] { + fn go(acc, xs: Stack[T]) { + match xs { + Empty => acc + NonEmpty(top, rest) => go((NonEmpty(top, acc) : Stack[T]), rest) + } + } + go(Empty, self) +} +``` + +Here is the code for the queue. You can see that we extensively apply generics, so our queue can contain any type, including queues containing other elements. The functions here are the specific implementations of the algorithm we just explained. In function `push`, you we called the stack's `push` function through `back.push()`. We will explain this specifically in the next lesson. + +## Higher-Order Functions + +This section continues to focus on how to use the features provided by MoonBit to reduce repetitive code and enhance code reusability. So, let’s start with an example. + +```moonbit +fn sum(list: List[Int]) -> Int { + match list { + Nil => 0 + Cons(hd, tl) => hd + sum(tl) + } +} +``` + +Consider some operations on lists. For instance, to sum an integer list, we use structural recursion with the following code: if empty, the sum is 0; otherwise, the sum is the current value plus the sum of the remaining list elements. + +```moonbit +fn length[T](list: List[T]) -> Int { + match list { + Nil => 0 + Cons(hd, tl) => 1 + length(tl) + } +} +``` + +Similarly, to find the length of a list of any data type, using structural recursion, we write: if empty, the length is 0; otherwise, the length is 1 plus the length of the remaining list. + +Notice that these two structures have considerable similarities: both are structural recursions with a default value when empty, and when not empty, they both involve processing the current value and combining it with the recursive result of the remaining list. In the summing case, the default value is 0, and the binary operation is additio; in the length case, the default value is also 0, and the binary operation is to replace the current value with 1 and then add it to the remaining result. How can we reuse this structure? We can write it as a function, passing the default value and the binary operation as parameters. + +### First-Class Function in MoonBit + +This brings us to the point that in MoonBit, functions are first-class citizens. This means that functions can be passed as parameters and can also be stored as results. For instance, the structure we just described can be defined as the function shown below, where `f` is passed as a parameter and used in line four for calculation. + +```moonbit +fn fold_right[A, B](list: List[A], f: (A, B) -> B, b: B) -> B { + match list { + Nil => b + Cons(hd, tl) => f(hd, fold_right(tl, f, b)) + } +} +``` + +Here’s another example. If we want to repeat a function’s operation, we could define `repeat` as shown in the first line. `repeat` accepts a function as a parameter and then returns a function as a result. Its operation results in a function that calculates the original function twice. + +```moonbit +fn repeat[A](f: (A) -> A) -> (A) -> A { + fn (a) { f(f(a)) } // Return a function as a result +} + +fn plus_one(i: Int) -> Int { i + 1 } +fn plus_two(i: Int) -> Int { i + 2 } + +let add_two: (Int) -> Int = repeat(plus_one) // Store a function + +let compare: Bool = add_two(2) == plus_two(2) // true (both are 4) +``` + +For example, if we have two functions `plus_one` and `plus_two`, by using `repeat` with `plus_one` as a parameter, the result is a function that adds one twice, i.e., adds two. We use `let` to bind this function to `add_two`, then perform calculations using normal function syntax to get the result. + +`let add_two: (Int) -> Int = repeat(plus_one)` + +  `repeat(plus_one)` + +$\mapsto$ `fn (a) { plus_one(plus_one(a)) }` + +`let x: Int = add_two(2)` + +  `add_two(2)` + +$\mapsto$ `plus_one(plus_one(2))` + +$\mapsto$ `plus_one(2) + 1` + +$\mapsto$ `(2 + 1) + 1` + +$\mapsto$ `3 + 1` + +$\mapsto$ `4` + +Let's explore the simplification here. First, `add_two` is bound to `repeat(plus_one)`. For this line, simplification is about to replace identifiers in expressions with arguments, obtaining a function as a result. Now, we cannot simplify further for this expression. Then, we Calculate `add_two(2)`. Similarly, we replace identifiers in the expression and simplify `plus_one`. After more simplifications, we finally obtain our result, `4`. + +We've previously mentioned function types, which go from the accepted parameters to the output parameters, where the accepted parameters are enclosed in parentheses. + +- `(Int) -> Int` Integers to integers +- `(Int) -> (Int) -> Int` Integers to a function that accepts integers and returns integers +- `(Int) -> ((Int) -> Int)` The same as the previous line +- `((Int) -> Int) -> Int` A function that accepts a function from integers to integers and returns an integer + +For example, the function type from integer to integer, would be `(Int) -> Int`. The second line shows an example from integer to function. Notice that the function’s parameter also needs to be enclosed in parentheses. The function type is actually equivalent to enclosing the entire following function type in parentheses, as seen in the third line. If it's from function to integer, as we mentioned earlier, the accepted parameter needs to be enclosed in parentheses, so it should look like the fourth line, not the second. + +### Example: Fold Functions + +Here are a few more common applications of higher-order functions. Higher-order functions are functions that accept functions. `fold_right`, which we just saw, is a common example. Below, we draw its expression tree. + +```moonbit no-check +fn fold_right[A, B](list: List[A], f: (A, B) -> B, b: B) -> B { + match list { + Nil => b + Cons(hd, tl) => f(hd, fold_right(tl, f, b)) + } +} +``` + +![](/pics/fold_right.drawio.webp) + +You can see that for a list from 1 to 3, `f` is applied to the current element and the result of the remaining elements each time, thus it looks like we're building a fold from right to left, one by one, to finally get a result. Therefore, this function is called `fold_right`. If we change the direction, folding the list from left to right, then we get `fold_left`. + +```moonbit +fn fold_left[A, B](list: List[A], f: (B, A) -> B, b: B) -> B { + match list { + Nil => b + Cons(hd, tl) => fold_left(tl, f, f(b, hd)) + } +} +``` + +![](/pics/fold_left.drawio.webp) + +Here, we only need to swap the order, first processing the current element with the previous accumulated result, then incorporating the processed result into the subsequent processing, as shown in the fourth line. This function folds from left to right. + +### Example: Map Function + +Another common application of higher-order functions is to map each element of a function. + +```moonbit no-check +struct PersonalInfo { name: String; age: Int } +fn map[A, B](self: List[A], f: (A) -> B) -> List[B] { + match list { + Nil => Nil + Cons(hd, tl) => Cons(f(hd), map(tl, f)) + } +} +let infos: List[PersonalInfo] = ??? +let names: List[String] = infos.map(fn (info) { info.name }) +``` + +For example, if we have some people's information and we only need their names, then we can use the mapping function `map`, which accepts `f` as a parameter, to map each element in the list one by one, finally obtaining a new list where the type of elements has become `B`. This function's implementation is very simple. What we need is also structural recursion. The last application is as shown in line 8. Maybe you feel like you've seen this `map` structure before: structural recursion, a default value for the empty case, and a binary operation processing the current value combined with the recursive result when not empty. Indeed, `map` can be entirely implemented using `fold_right`, where the default value is an empty list, and the binary operation is the `Cons` constructor. + +```moonbit +fn map[A, B](list: List[A], f: (A) -> B) -> List[B] { + fold_right(list, fn (value, cumulator) { Cons(f(value), cumulator) }, Nil) +} +``` + +Here we leave you an exercise: how to implement `fold_left` with `fold_right`? Hint: something called `Continuation` may be involved. `Continuation` represents the remaining computation after the current operation, generally a function whose parameter is the current value and whose return value is the overall program's result. + +Having learned about generics and higher-order functions, we can now define the binary search tree studied in the last lesson as a more general binary search tree, capable of storing various data types, not just integers. + +```moonbit no-check +enum Tree[T] { + Empty + Node(T, Tree[T], Tree[T]) +} + +// We need a comparison function to determine the order of values +// The comparison function should return an integer representing the comparison result +// -1: less than; 0: equal to; 1: greater than +fn insert[T](self: Tree[T], value: T, compare: (T, T) -> Int) -> Tree[T] +fn delete[T](self: Tree[T], value: T, compare: (T, T) -> Int) -> Tree[T] +``` + +Here, the data structure itself accepts a type parameter to represent the data type it stores. Considering that a binary search tree should be ordered, we need to know how to sort this specific type, hence we accept a comparison function as a parameter, which should return an integer representing the comparison result as less than, equal to, or greater than, as the code shows. Indeed, we could completely use another feature of MoonBit to omit this parameter. We will introduce this in the next lesson. + +## Summary + +In this chapter, we introduced the concepts of generics and functions as first-class citizens, and we saw how to use them in MoonBit. We also discussed the implementations of the data structures stack and queue. + +For further exploration, please refer to: + +- _**Software Foundations, Volume 1: Logical Foundations**_: Poly; or +- _**Programming Language Foundations in Agda**_: Lists \ No newline at end of file diff --git a/static/pics/abstract-tree-en.drawio.webp b/static/pics/abstract-tree-en.drawio.webp new file mode 100644 index 0000000..ec43b96 Binary files /dev/null and b/static/pics/abstract-tree-en.drawio.webp differ diff --git a/static/pics/bfs-en.drawio.webp b/static/pics/bfs-en.drawio.webp new file mode 100644 index 0000000..09ac71b Binary files /dev/null and b/static/pics/bfs-en.drawio.webp differ diff --git a/static/pics/bst-deletion-en.drawio.webp b/static/pics/bst-deletion-en.drawio.webp new file mode 100644 index 0000000..d6556ca Binary files /dev/null and b/static/pics/bst-deletion-en.drawio.webp differ diff --git a/static/pics/bst-en.drawio.webp b/static/pics/bst-en.drawio.webp new file mode 100644 index 0000000..e9e60e0 Binary files /dev/null and b/static/pics/bst-en.drawio.webp differ diff --git a/static/pics/not-a-tree-en.drawio.webp b/static/pics/not-a-tree-en.drawio.webp new file mode 100644 index 0000000..64f4a21 Binary files /dev/null and b/static/pics/not-a-tree-en.drawio.webp differ diff --git a/static/pics/queue-en.drawio.webp b/static/pics/queue-en.drawio.webp new file mode 100644 index 0000000..d042f79 Binary files /dev/null and b/static/pics/queue-en.drawio.webp differ diff --git a/static/pics/stack-push-pop-en.drawio.webp b/static/pics/stack-push-pop-en.drawio.webp new file mode 100644 index 0000000..7255a68 Binary files /dev/null and b/static/pics/stack-push-pop-en.drawio.webp differ diff --git a/static/pics/traversal-en.drawio.webp b/static/pics/traversal-en.drawio.webp new file mode 100644 index 0000000..51995f4 Binary files /dev/null and b/static/pics/traversal-en.drawio.webp differ diff --git a/static/pics/worst-bst-en.drawio.webp b/static/pics/worst-bst-en.drawio.webp new file mode 100644 index 0000000..d0f1409 Binary files /dev/null and b/static/pics/worst-bst-en.drawio.webp differ