Sorting
Table of Contents
In part adapted from chapters 10 and 12 of Building Java Programs, Marty Stepp and Stuart Reges. Also adapted from Stuart Reges: https://courses.cs.washington.edu/courses/cse143/20wi/notes/notes16.html
1 Reading
Read Bailey Section 5.2.1 on Recursion (p. 94–100 of the text, 110–116 of the pdf)
2 Video
Here is a video lecture for the material outlined below.
The video contains sections on
- Learning Objectives (0:00)
- Part 1: comparing objects (0:24)
compareTo
(1:15)Comparable
interface (5:11)- Implementing a comparable date class (7:08)
- Part 2: recursion (12:13)
- Recursive example (15:27)
- Part 3: merge sort (24:57)
- Visual merge sort (26:45)
- Merge procedure (32:42)
- Sort procedure (36:10)
- Analyzing merge sort (39:48)
- Visualization links (43:25)
The Panopto viewer has table of contents entries for these sections: https://carleton.hosted.panopto.com/Panopto/Pages/Viewer.aspx?id=a4c5c1d7-e2b9-4378-9c51-acbb0176231c
3 Learning Goals
After this lesson you will be able to
- compare objects in Java
- write a simple recursive function
- describe mergesort, including how it uses recursion, and its big-O efficiency
4 Comparing Objects in Java
The method Collections.sort
can be used to sort an ArrayList
(or anything that implements the List
interface). It is part of the java.util
package. The following short program demonstrates how to use Collections.sort
:
// Constructs an ArrayList of Strings and sorts it. import java.util.*; public class SortExample { public static void main(String[] args) { ArrayList<String> words = new ArrayList<>(); words.add("four"); words.add("score"); words.add("and"); words.add("seven"); words.add("years"); words.add("ago"); // show list before and after sorting System.out.println("before sort, words = " + words); Collections.sort(words); System.out.println("after sort, words = " + words); } }
This program produces the following output:
before sort, words = [four, score, and, seven, years, ago] after sort, words = [ago, and, four, score, seven, years]
But how does Java know which strings come before others?
It uses the String
class's compareTo
method: public int compareTo(String anotherString)
.
A boolean
return type can't be used because there are three possible answers: less than, equal to, or greater than. The convention for compareTo
is that an object should return one of the following results:
- A negative number to indicate a less-than relationship
- 0 to indicate equality
- A positive number to indicate a greater-than relationship
For example, consider a similar piece of code that compares String values:
String x = "hello"; String y = "world"; String z = "hello"; System.out.println(x.compareTo(y)); System.out.println(x.compareTo(z)); System.out.println(y.compareTo(x));
The compareTo
method of the String
class compares strings alphabetically, so the strings in this code have the following relationships: x
is less than y
because in an alphabetical list "hello" comes before "world", x
is equal to z
because the two occurrences of "hello" are equal, and y
is greater than x
because in an alphabetical list "world" comes after "hello". But the output produced might be slightly surprising:
–15 0 15
If you're curious about why these particular values are returned, you can read the documentation for the String
compareTo
method.
The important takeaway is that any negative number returned by compareTo
indicates less than and any positive number indicates greater than.
It's also worth noting that you can't use the relational operators to compare strings. The following code will not compile:
// illegal--can't compare objects this way String s1 = "hello"; String s2 = "world"; if (s1 < s2) { System.out.println("s1 less than s2"); }
Instead, call the compareTo
method, as in:
String s1 = "hello"; String s2 = "world"; if (s1.compareTo(s2) < 0) { System.out.println("s1 less than s2"); }
It's not just strings that use compareTo
. Any type that can be ordered (i.e., we can say one object is less than another) by implementing the Comparable
interface:
public interface Comparable<T> { public int compareTo(T other); }
Here's a table for how comparisons of primitive types like int
are written for objects using compareTo
:
Many of the standard Java classes, such as String
, implement the Comparable
interface. You can have your own classes implement the interface as well. Implementing the Comparable
interface will open up a wealth of off-the-shelf programming solutions that are included in the Java class libraries. For example, there are built-in methods for sorting lists and for speeding up searches.
As a fairly simple example, let's explore a class that can be used to keep track of a calendar date. The idea is to keep track of a particular month and day, but not the year. For example, the United States will celebrate Labor Day on September 6 this year. Similarly, an organization might want a list of its employees' birthdays that doesn't indicate how old they are.
We can implement this class of dates with two fields to store the month and day:
public class CalendarDate { private int month; private int day; public CalendarDate(int month, int day) { this.month = month; this.day = day; } // other methods }
Remember that to implement an interface, you include an extra notation in the class header. Implementing the Comparable
interface is a little more challenging because it is a generic interface (Comparable<T>
). We can't simply write code like the following:
// not correct public class CalendarDate implements Comparable { ... }
We have to replace the <T>
in Comparable<T>
. Whenever you implement Comparable
, you compare pairs of values from the same class. So a class called CalendarDate
should implement Comparable<CalendarDate>
. If you look at the header for the Integer
class, you will find that it implements Comparable<Integer>
. Likewise, the String
class implements Comparable<String>
. So we need to change the header to the following one:
public class CalendarDate implements Comparable<CalendarDate> { ... }
Of course, claiming to implement the interface is not enough. We also have to include appropriate methods. In this case, the Comparable
interface includes just a single method:
public interface Comparable<T> { public int compareTo(T other); }
Because we are using CalendarDate
in place of T
, we need to write a compareTo
method that takes a parameter of type CalendarDate
:
public int compareTo(CalendarDate other) { ... }
Now we have to figure out how to compare two dates. Each CalendarDate
object will contain fields that store the month and day. With calendars, the month takes precedence over the day. If we want to compare January 31 (1/31) with April 5 (4/5), we don't care that 5 comes before 31; we care instead that January comes before April. So, as a first attempt, consider the following method that compares only the months:
// compares only the months public int compareTo(CalendarDate other) { if (month < other.month) { return –1; } else if (month == other.month) { return 0; } else { // month > other.month return 1; } }
We need to consider more than just the month, but before we do that, we can improve on what we have here. This code uses an if/else construct to return values of –1, 0, and 1, but a simpler option is available. We can simply return the difference between month
and other.month
, because it will be negative when month
is less than other.month
, it will be 0 when they are equal, and it will be positive when month
is greater than other.month
. So, we can simplify the code as follows:
// still uses only the month, but more compact public int compareTo(CalendarDate other) { return month – other.month; }
It is a good idea to keep things simple when you can, so this version is preferable to the original one. It returns slightly different values than the earlier version, but it satisfies the contract of the Comparable
interface just as well. However, the code still has a problem.
Consider, for example, a comparison of April 1 (4/1) and April 5 (4/5). The current version of compareTo
would subtract the months and return a value of 0, indicating that these two dates are equal. However, the dates aren't equal: April 1 comes before April 5.
The day of the month becomes important only when the months are equal. If the months differ, we can use the months to determine order. Otherwise (when the months are equal), we must use the day of the month to determine order. This common ordering principle is present in many tasks. We can implement it as follows:
public int compareTo(CalendarDate other) { if (month != other.month) { return month – other.month; } else { return day – other.day; } }
It is still possible for this code to return 0. Suppose that we have two CalendarDate
objects that both store the date April 5 (4/5). The months are equal, so the program returns the difference between the days. That difference is 0, so the program returns 0, which correctly indicates that the two dates are equal.
5 Review of Recursion
- non-computing example: figuring out your place in line
- recursive function:
- base case => we're done
- otherwise, make progress and make a recursive call
- recursive data structure
- linked list has a head node followed by a linked list
- will see more examples when we get to trees
5.1 Example
Suppose you have a Scanner
that is tied to an external input file and you want to print the lines of the file in reverse order. For example, the file might contain the following four lines of text:
this is fun no?
Printing these lines in reverse order would produce this output:
no? fun is this
To perform this task iteratively (i.e., using a loop), you'd need some kind of data structure for storing the lines of text, such as an ArrayList<String>
.
However, recursion allows you to solve the problem without using a data structure.
Remember that recursive programming involves thinking about cases. What would be the simplest file to reverse? A one-line file would be fairly easy to reverse, but it would be even easier to reverse an empty file. So, you can begin writing your method as follows:
public static void reverse(Scanner input) { if (!input.hasNextLine()) { // base case (empty file) ... } else { // recursive case (nonempty file) ... } }
In this problem, the base case is so simple that there isn't anything to do. An empty file has no lines to reverse. Thus, in this case it makes more sense to turn around the if/else statement so that you test for the recursive case. That way you can write a simple if statement that has an implied "else there is nothing to do":
public static void reverse(Scanner input) { if (input.hasNextLine()) { // recursive case (nonempty file) ... } }
Again, the challenge is to solve only a little bit of the problem. How do you take just one step that will get you closer to completing the task? You can read one line of text from the file:
public static void reverse(Scanner input) { if (input.hasNextLine()) { // recursive case (nonempty file) String line = input.nextLine(); ... } }
For the sample file, this code would read the line "this" into the variable line and leave you with the following three lines of text in the Scanner:
is fun no?
Recall that your aim is to produce the following overall output:
no? fun is this
You might be asking yourself questions like, "Is there another line of input to process?"
But that's not recursive thinking.
If you're thinking recursively, you'll be thinking about what a call on the method will get you.
Since the Scanner
is positioned in front of the three lines "is", "fun", and "no?", a call on reverse
should read in those lines and produce the first three lines of output that you're looking for.
If that works, you'll only have to write out the line "this" afterward to complete the output.
This is where the leap of faith comes in—you have to believe that the reverse
method actually works. If it does, this code can be completed as follows:
public static void reverse(Scanner input) { if (input.hasNextLine()) { // recursive case (nonempty file) String line = input.nextLine(); reverse(input); System.out.println(line); } }
This code does work. To reverse a sequence of lines, simply read in the first one, reverse the others, and then write out the first one.
Let's break down what reverse
does on our small example.
The idea of representing each method call as a piece of paper and putting each one on top of the others as it is called is a metaphor for Java's call stack.
If you envision the call stack as a stack of papers with the most recently called method on top, you'll have a pretty good idea of how it works.
Let's use the idea of the call stack to understand how the recursive file-reversing method works. To visualize the call stack, we need to put the method definition on a piece of paper:
Notice that the paper includes a place to store the value of the local variable line
. This is an important detail.
Suppose that we call this method with the earlier sample input file, which contains the following four lines of text:
this is fun no?
When we call the method, it reads the first line of text into its line variable, and then it reaches the recursive call on reverse
:
Then what happens? To understand what happens, you have to realize that each method invocation is independent of the others. We don't have only a single sheet of paper with the reverse method written on it; we have as many copies as we want. So, we can grab a second copy of the method definition and place it on top of the current one:
This new version of the method has a variable of its own, called line
, in which it can store a line of text.
Even though the previous version (the one underneath this one) is in the middle of its execution, this new one is at the beginning of its execution.
Think of the analogy of being able to employ an entire army of people to help print out the lines in reverse.
You can call on as many people as you need to solve the problem.
In this case, you can bring up as many copies of the reverse
method as you need to solve this problem.
The second call on reverse
reads another line of text (the second line, "is"). After the program reads the second line, it makes another recursive call on reverse
:
So Java sets aside this version of the method as well and brings up a third version:
Again, notice that this version has its own variable called line
that is independent of the other variables called line
. This version of the method also reads in a line (the third line, "fun") and reaches a recursive call on reverse
:
This brings up a fourth version of the method:
This version finds a fourth line of input ("no?"), so it reads that in and reaches the recursive call:
This call brings up a fifth version of the method:
This version turns out to have the easy task.
This time around the Scanner
is empty (input.hasNextLine()
returns false
).
The program has reached the very important base case that stops this process from going on indefinitely.
This version of the method recognizes that there are no lines to reverse, so it simply terminates.
Then what? Having completed this call, we throw it away and return to where we were just before executing the call:
We've finished the call on reverse
and are positioned at the println
right after it, so we print the text in the line
variable ("no?") and terminate.
Where does that leave us?
This method has been executed and we return to where we were just before:
We then print the current line of text, which is "fun", and this version also goes away:
Now we execute this println
, for the text "is", and eliminate one more call:
Notice that we've written out three lines of text so far:
no? fun is
Our leap of faith was justified.
The recursive call on reverse
read in the three lines of text that followed the first line of input and printed them in reverse order.
We complete the task by printing the first line of text, which leads to this overall output:
no? fun is this
Then this version of the method terminates, and the program has finished executing.
6 Merge Sort
Merge sort is an example of a divide and conquer approach: recursively split the problem into smaller pieces and solve those. These kind of algorithms are common in computer science and we will see more examples as we go through the course.
6.1 Why Merge Sort?
- It's a very efficient sorting algorithm
- Java (and many other programming libraries) use an algorithm based on merge sort for their built-in sorting
- It's a comparison sort meaning that it involves comparing objects to each other and thus uses the
compareTo
method we've just learned about - It provides some practice with recursion
- Computer scientists are generally expected to know about sorting
6.2 Visualization
To get us started here's a visual example of what merge sort will do:
[13, 42, 97, -3, 53, 18, 92, 50] / \ / \ [13, 42, 97, -3] [53, 18, 92, 50] / \ / \ / \ / \ [13, 42] [97, -3] [53, 18] [92, 50] / \ / \ / \ / \ / \ / \ / \ / \ [13] [42] [97] [-3] [53] [18] [92] [50] \ / \ / \ / \ / \ / \ / \ / \ / [13, 42] [-3, 97] [18, 53] [50, 92] \ / \ / \ / \ / [-3, 13, 42, 97] [18, 50, 53, 92] \ / \ / [-3, 13, 18, 42, 50, 53, 92, 97]
It will start by splitting up the list into smaller and smaller sublists (dividing in half each time). Once it's down to single-element lists (which don't need to be sorted), it starts merging the sublists back together. It's during this merge step that the sorting actually takes place, so let's start there.
6.3 merge
Function
Take two sorted lists, combine them into a single sorted list. Pseudocode:
Given two sorted lists, left and right, and an empty list, merged while elements remain in both left and right compare the first element in left to the first element in right remove the smaller one from its list and append it to merged append any remaining elements from left or right to merged
Let's begin by writing a method that takes three lists as arguments. The first list will be empty and that will be where we want to store our result. The other two lists will each contain a sorted list of values. The idea is to merge the two sorted lists into one list. By the time we're done, we want all of the values to be in the first list in sorted order and we want the other two lists to be empty.
So the header for our method would look like this (for sorting strings):
public static void merge(Queue<String> result, Queue<String> list1, Queue<String> list2) {
So we have two sorted lists and we want to merge them together. How do we do it? A good wrong answer is to say that we glue the two lists together and call a sorting routine. We want to take advantage of the fact that the two lists are already sorted. Perhaps we'd want to look at the first value in each list and pick the smaller one. Then we'd move that value into our result.
if (list1.peek() < list2.peek()) { // move front of list1 to result }
This has the right logic, but we have to work out the syntax.Remember that we can't compare String
objects this way (str1 < str2
). Instead, we have to take advantage of the fact that String
implements the Comparable
interface and use its compareTo
method instead. We also have to fill in how to move something from the front of one of the two lists into our result.
if (list1.peek().compareTo(list2.peek()) < 0) {
result.add(list1.remove());
}
And what do we do if the first value of list1
is not less than or equal to the first value of list2
? Then we'd want to take from the other list:
if (list1.peek().compareTo(list2.peek()) < 0) { result.add(list1.remove()); } else { result.add(list2.remove()); }
Of course, this just says how to handle one value. We want to keep doing this as long as their are values left to compare, so we need this inside a loop. So we want to continue while both lists are nonempty:
!list1.isEmpty() && !list2.isEmpty()
So using this as a loop test our code becomes:
while (!list1.isEmpty() && !list2.isEmpty()) { if (list1.peek().compareTo(list2.peek()) < 0) { result.add(list1.remove()); } else { result.add(list2.remove()); } }
So this loop will take values from one list or the other while they both have something left to compare. Eventually one of the lists will become empty. Then what? Suppose it's the second list that becomes empty first. What do we do with the values left in the first list? Every one of them is larger than the values in the second list and they're in sorted order. So all we have to do is transfer them from the first list to the result.
while (!list1.isEmpty()) { result.add(list1.remove()); }
This is the right code to execute if the second list is the one that has gone empty. But what if it's the first list that has gone empty? Then you'd want to do a corresponding transfer from the second list:
while (!list2.isEmpty()) { result.add(list2.remove()); }
You might think that we need an if/else to figure out whether it's the first case or the second case, but it turns out that an if/else would be redundant. The loops already have tests to see if the list is empty. So we can simply execute both loops. What will end up happening is that one list will have something left in it, so one of these loops will execute, and the other list will be empty, in which case the other loop doesn't have any effect. So the final code is as follows:
public static void mergeInto(Queue<String> result, Queue<String> list1, Queue<String> list2) { while (!list1.isEmpty() && !list2.isEmpty()) { if (list1.peek().compareTo(list2.peek()) < 0) { result.add(list1.remove()); } else { result.add(list2.remove()); } } while (!list1.isEmpty()) { result.add(list1.remove()); } while (!list2.isEmpty()) { result.add(list2.remove()); } }
6.4 Recursively Sorting
Now let's turn our attention to how to use this merge
method to sort a list. Sticking with a queue of strings, we want to write a method like this:
public static void sort(Queue<String> list) { ... }
If we want to think recursively, we can begin by thinking about base cases. What would be an easy list to sort? An empty list seems pretty easy—it doesn't need to be sorted at all. It's also true that a list of 1 element also doesn't need to be sorted. If there is only one thing, there is nothing else around to be out of order with it. This is one of those cases where we don't have to do anything in the base case. So we can write a simple if statement with a test for when there's actual sorting to do:
public static void sort(Queue<String> list) { if (list.size() > 1) { ... } }
Now we should think about how we could split such a list into two lists. We'd need some variables:
Queue<String> half1 = new LinkedList<String>(); Queue<String> half2 = new LinkedList<String>();
How many things should end up in each list? We want to divide the initial list in half, so list.size()
divided by 2 is probably what we want. That's almost right, but we have to worry about the size being odd. An easy way to make this work is to set one of the sizes to list.size()
divided by 2 and to set the other to list.size()
minus the first one:
int size1 = list.size() / 2; int size2 = list.size() - size1;
So now it's just a matter of transferring items from the list to the two new lists. We can do so with simple for loops:
for (int i = 0; i < size1; i++) { half1.add(list.remove()); } for (int i = 0; i < size2; i++) { half2.add(list.remove()); }
So where does that leave us? We have two lists, each with half of the items from the original list. That means that our original list is now empty. And we also know that we have a way to merge two sorted lists together (the method merge
that we wrote earlier). But will that work? Unfortunately not. These two lists aren't necessarily sorted. They're just the first and second half of the original list.
What are we to do??? Don't give up hope, recursion will come to our rescue! We've reached a point where we have two lists, each with half of the items from the original list. We need them to be sorted. If only we had a method for sorting a list, then we could call it. But we have such a method. We're writing it! So we sort these two sublists:
sort(half1); sort(half2);
And once the two are sorted, we can merge them together putting them back into the original list using the method we wrote a minute ago:
merge(list, half1, half2);
And that's the entire method. We're done. Putting all the pieces together, we ended up with:
public static void sort(Queue<String> list) { if (list.size() > 1) { Queue<String> half1 = new LinkedList<String>(); Queue<String> half2 = new LinkedList<String>(); int size1 = list.size() / 2; int size2 = list.size() - size1; for (int i = 0; i < size1; i++) { half1.add(list.remove()); } for (int i = 0; i < size2; i++) { half2.add(list.remove()); } sort(half1); sort(half2); merge(list, half1, half2); } }
6.5 Analysis
merge
is \(O(n)\) (we have to compare and/or insert each element of the two sublists)- how many merges?
- number of times \(n\) can be divided by 2 before we reach the base case: \(O(\log_2 n)\)
- so overall, mergesort is \(O(n\log_2 n)\)
- There is, in fact, no general sorting algorithm with better big-O efficiency than \(O(n\log_2 n)\)
7 Visualizations
8 Practice Problems1
- One day you notice that integer multiplication no longer works. Write a recursive procedure to multiply two values \(a\) and \(b\) using only addition.
- Consider the following variable declarations:
Integer n1 = 15; Integer n2 = 7; Integer n3 = 15; String s1 = "computer"; String s2 = "soda"; String s3 = "pencil";
Indicate whether the result of each of the following comparisons is positive, negative, or 0:
n1.compareTo(n2)
n3.compareTo(n1)
n2.compareTo(n1)
s1.compareTo(s2)
s3.compareTo(s1)
s2.compareTo(s2)
- We have the
Student
class shown below. We want to be able to sort a list ofStudent
objects such that they will be grouped by class year and then be in alphabetical order by name within each year. For example, all class of 2021 students should come before all class of 2022 students, and within the class of 2021 "Aaron Bauer" should come before "Aron Bowers". Write acompareTo
method for thisStudent
class that will sortStudent
objects first by class year and then by name.public class Student implements Comparable<Student> { private String name; private int year; public Student(String name, int year) { this.name = name; this.year = year; } // other methods }
- What are base cases and recursive cases? Why does a recursive method need to have both?
- Consider the following method:
public static void mystery2(int n) { if (n > 100) { System.out.print(n); } else { mystery2(2 * n); System.out.print(", " + n); } }
For each of the following calls, indicate the output that is produced by the method:
mystery2(113);
mystery2(70);
mystery2(42);
mystery2(30);
mystery2(10);
- What would be the effect if the code for the reverse method were changed to the following?
public static void reverse(Scanner input) { if (input.hasNextLine()) { // recursive case (nonempty file) String line = input.nextLine(); System.out.println(line); // swapped order reverse(input); // swapped order } }
- What would be the effect if the code for the reverse method were changed to the following?
public static void reverse(Scanner input) { if (input.hasNextLine()) { // recursive case (nonempty file) reverse(input); // moved this line String line = input.nextLine(); System.out.println(line); } }
- Write a method
recursivePrintHelper
for theDoublyLinkedList
from Lab 2 that takes aListNode
and recursively prints out the linked list starting with that node (without using a loop). Then write a methodrecursivePrint
that callsrecursivePrintHelper
to print out whole list.
9 Learning Block
- hearing the difference between linear and constant time
- questions?
- form question
- practice problem 8 in pods
- demo VS Code live share
Footnotes:
Solutions:
public int mult(int a, int b) { if (a < 0) return -mult(-a,b); if (a == 0) return 0; if (a < b) return mult(a-1,b)+b; else return mult(b,a); }
n1.compareTo(n2) > 0
n3.compareTo(n1) == 0
n2.compareTo(n1) < 0
s1.compareTo(s2) < 0
s3.compareTo(s1) > 0
s2.compareTo(s2) == 0
public int compareTo(Student other) { if (year != other.year) { return year - other.year; } else { return name.compareTo(other); } }
- A base case is a situation where a recursive method does not need to make a recursive call to solve the problem. A recursive case is a situation where the recursive method does call itself. Recursive methods need both cases because the recursive case is called repeatedly until the base case is reached, stopping the chain of recursive calls.
- Output produced by the mystery2 method by each call:
- 113
- 140, 70
- 168, 84, 42
- 120, 60, 30
- 160, 80, 40, 20, 10
- The new code shown would print the lines in their original order, not reversed.
- The new code shown would cause infinite recursion, because each recursive call just makes another recursive call and doesn't progress toward the base case.
public void recursivePrintHelper(ListNode node) { // base case: node is tail and we're at the end of the list, nothing left to print if (node != tail) { // recursive case System.out.println(node.value + " "); // print out the current node // recursively print the rest of the list (if we're not at the end) recursivePrintHelper(node.next); } } public void recursivePrint() { recursivePrintHelper(head.next); }