Unit 7: Sorting and Searching
(Credit to Chris Brown for the original version of these notes.)
We’re not going to learn any new C syntax in this unit. Instead, we’ll see some powerful ways that the previous two major topics — functions and arrays — can together solve two of the most common things we want a computer to do: organize a bunch of data (sorting) and look something up (searching). Beyond being very common things to want to program, the way we introduce these problems will help us better understand good program design so that we get better at writing all sorts of programs.
1 Semi-Review: Function Design
1.1 What we’ve learned about functions
The ideas behind functions in programming are very important, and we’ve covered a lot of ground in the past few weeks. For example:
- prototype versus definition
- return type and arguments/parameters
- passing a copy of a value to a function
- passing a pointer to a value to a function
- passing a pointer to an array to a function
- implicit type conversion for function arguments
- recursion
- local variables and scope
- “the stack” of function calls
- Top-down design
When looking at this list of topics remember this: the most important thing to understand about functions in how they help us solve programming problems. A function can be viewed from the perspective of a user or from the perspective of an implementer. The user doesn’t care about how a function does something; they care about what the function does. For example, you (and I) have no idea how the cos
function from cmath
works, but we do know what it does (give it a double
r and it returns a double
representing the cosine of r), and that’s enough to use it.
Essentially, the prototype (along with a comment or two) is all that the user of a function needs to know. The definition of the function is the realm of the implementer, who, of course, has to figure out how the function is going to do what it’s supposed to.
When you work in groups, one person simply uses a certain set of functions in their part of the code, and the other person implements those functions. It’s a convenient way to divide up work, because once we agree on what the functions are supposed to do (i.e. figure out prototypes, etc) we can go our separate ways and work on our own.
Within a program you write by yourself, functions are equally helpful, because they allow you to break up a large program into pieces that you can solve separately. Thus, no one piece is a big deal. In such cases, you are both user and implementer at different times - but never both at once!
1.2 What makes code great
Programming nirvana - the most enlightened state of programming - is reached when you have perfectly achieved two goals:
separation of interface from implementation, i.e. what is needed in order to use a thing is completely walled off from how the thing actually works, and
code reuse, i.e. code you write for one project can be easily reused in all sorts of other projects, or reused within the same project (never duplicate code!).
These two goals are independent of what language you program in; this is what everyone wants. In the paradigm (or style) of programming we cover in SI204, which is called procedural programming, the primary language construct provided to support these two goals is … functions! The prototype vs definition split, and the scoping rules for local variables within functions provide separation (perhaps imperfectly) of interface and implementation. Viewing programs as collections of functions provides code reuse, as well-designed functions from one project can be taken piecemeal and reused in other projects.
1.3 Bottom-Up Design
In top-down design, you are a user first, as you write a program assuming the existence of a “wish-list” of functions. Then you go ahead and implement each of these functions separately - and separate from the larger program. If you did things the other way ’round, it would be bottom-up design.
In bottom-up design you are the implementer of little functions first, and later you get around to using them in a larger program. For example, suppose you knew you were going to have to write a program that did some calculations with vectors for some basic physics problems. It’s clear that you’ll probably want to read, write, and add vectors, and probably you’ll want to do dot products and get the length of vectors. Putting all this together, and remembering that a vector will consist of two double
values, we get the following prototypes:
// Read vector from stream in and return through pointers variables x and y
void read(double* x, double* y, FILE* in);
// Write the vector (x,y) to stream out
void write(double x, double y, FILE* out);
// Add vectors (x1,y1) and (x2,y2) and return through pointers variables x and y
void add(double x1, double y1, double x2, double y2, double* x, double* y);
// Return the dot product of (x1,y1) and (x2,y2)
double dot(double x1, double y1, double x2, double y2);
// Return the length of (x,y)
double length(double x, double y);
Whatever it is that I ultimately do in my program, if I have these functions I’ll probably be OK with the vector part. So I’ll go ahead and implement these now, and worry about the larger program later. That’s what bottom-up design is about.
Bottom-up and top-down design aren’t mutually exclusive either; you can write a bunch of prototypes (top-down), then implement a few of them (bottom-up), then start to fill in your main
(top-down), and so on. The main point is that there are multiple ways to tackle a big programming problem, depending on the situation at hand. Bottom-up design is an especially good way to get started with something when you’re not sure what to do first on a tough problem.
1.4 Bottom-up design example
Suppose I have the following game: Some number of coins are spread in a row across a table, in a random order, also with a random side facing up. Play commences in turns. For their turn, a player has three options:
- Take a coin (or two-coin pile, as we’ll see later) from one of the two ends of the coin row, and add the coin(s) to the player’s stash, keeping the same side(s) up. Note: piles stay piles in the stash.
- Take an uncovered coin from the player’s stash and place it (same side up) on an uncovered coin in the row.
- Flip an uncovered coin (or the top coin in a two-coin pile) in the coin row — but this is only allowed if the player’s previous move was not a flip!
When the coin row is empty, each player sums up the values of the coins in their hands, counting heads-up coins as + their values, and tails-up coins as - their value. Whoever has the largest sum wins.
Let’s do bottom-up design. The fundamental object we have to deal with here are coin piles, which may consist of one or two coins.
First let’s think about how to represent a pile of coins as a single number. We might represent side value as heads-up = 0, tails-up = 1, and coins by their denomination, i.e. 25 for quarters, 10 for dimes, 5 for nickels and 1 for pennies.
// one-coin piles:
pilevalue = sidevalue + 10*coinvalue
// two-coin piles:
pilevalue = sidevalueTop + 10*coinvalueTop + 1000*(sidevalueBottom + 10* coinvalueBottom)
So, for example, if you have a quarter facing heads up, it would have pilevalue = \(0 + 10\cdot 25 = 250\). If you have a tails-up dime with a tails-up quarter on top, you would have pilevale = \(1 + 10\cdot 10 + 1000\cdot(1 + 10\cdot 25)\). We can unpack this stuff as follows (let pv
be a pile value):
if (pv < 1000) {
// then pv represents a 1-coin pile
} else {
// then pv represents a 2-coin pile
}
pv%10 // the sidevalue of the top coin
(pv%100)/10 // the coinvalue of the top coin
(pv/1000)%10 // the sidevalue of the bottom coin (if there is one)
(pv/10000) // the coin value of the bottom coin (if there is one)
Your job: use bottom-up design and start writing prototypes and definitions for the functions you’d probably want to have if you were ultimately going to implement this game!
2 Sorting
2.1 Indices rather than elements
Often with arrays it’s more important to return the index of an element with a certain property, rather than the value of the element. For example, if char** names
is an array of names of contest participants, and int* scores
is an array of scores, such that participant names[i]
has score scores[i]
, then knowing the value of the largest element in scores
won’t tell me who the winner is, but knowing the index of the largest element in scores
will.
So, with this in mind, let’s consider writing a function indexOfMax
that will return the index of the element with the maximum value in an array data
of size
objects of type int
.
int indexOfMax(int* data, int size) {
int imax = 0;
for (int i=1; i < size; ++i) {
if (data[i] > data[imax]) {
imax = i;
}
}
return imax;
}
Very simple function! Now, using our above example, the winner of our contest is names[indexOfMax(scores, size)]
.
2.2 Selection Sort
Consider the following function that uses our indexOfMax
function:
void mystery(int* data, int size) {
for(int length = size; length > 1; --length) {
int k = indexOfMax(data,length);
int temp = data[length-1];
data[length-1] = data[k];
data[k] = temp;
}
}
What does the mystery
function do? Well, it starts by swapping the largest element in the array with the last element of the array, and then pretends the last array slot isn’t there any more. Hopefully, you see that this puts the largest at the back of the array, the next largest in the second to last spot, etc. until the array is in sorted order!
This sorting algorithm is known as Selection Sort, because it selects the largest of the remaining elements and puts it in its proper spot in the sorted array. If you define the swap
function (as in Unit 5), you can write a particularly succinct version of this function:
void selectionSort(int* data, int size) {
for(int length = size; length > 1; --length) {
swap(&data[indexOfMax(data, length)], &data[length-1]);
}
}
Make sure you understand what the &
characters are doing there in the arguments to swap
!
2.3 Comparison Functions and Sorting
It is important to note that there is no one “sorted order” once and for all. We get different orders depending on how we compare values. To make this a little more explicit, let’s imagine our indexOfMax
function was changed to look like this:
int indexOfMax(int* data, int size) {
int imax = 0;
for (int i = 1; i < size; ++i) {
if (before(A[imax], A[i])) {
imax = i;
}
}
}
Notice that the comparison that we did with the built-in >
operator originally is now done by a function before
. That function clearly takes two integers and returns whether or not one is less than the other one:
int before(int a, int b) {
return a < b;
}
This version tells you that you want a
to come before b
if a
is smaller, i.e. we’ve got our same old smallest-to-largest sorted order. If, on the other hand, before
is defined like this:
int before(int a, int b) {
return a > b;
}
then we’ll get largest-to-smallest sorted order. Think of before
as a comes before predicate. I use before
to tell selection sort how to determine if one value should come before another element.
Taking this example one step further, perhaps I want to sort numbers by increasing absolute value, with the negative number coming first if we have opposite pairs in the array. Then I might define before
like this:
int before(int a, int b) {
if (a != -b) {
return abs(a) < abs(b);
} else {
return a <= 0;
}
}
Now we get an ordering by smallest-to-largest absolute values, with ties (i.e elements with equal absolute value and opposite signs) putting the positives to the back. For example,
45 32 -12 -32 0 -18 6
would be “sorted” using this before
function as:
0 6 -12 -18 -32 32 45
This is an important concept. The sorted order produced by a sorting algorithm like Selection Sort depends on the definition of your before
function - the same sorting algorithm works for any comparison function! In fact, when we get more advanced, we’ll even pass the before
function as a parameter to selection sort! Of course, we don’t know how to do that yet!
Note: Most places you see selection sort they don’t break indexOfMax
and swap
out into their own functions. Instead, they incorporate the whole thing into a nested loop, like this:
void selectionSort(int* data, int size) {
for(int length = size; length > 1; --length) {
// Find imax, the index of the largest
int imax = 0;
for(int i = 1; i < length; ++i) {
if (before(data[imax], data[i])) {
imax = i;
}
}
// Swap data[imax] & the last element
int temp = data[imax];
data[imax] = data[length - 1];
data[length - 1] = temp;
}
}
2.4 Sorting arrays of any type
We might decide that we want to sort an array of strings rather than an array of int
s? What has to change in our selection sort algorithm? Not much! As you can see from this program, nothing really changes in the sorting part except the names of the types we’re working with. And the before
function just needs to use strcmp
instead of the built-in comparison operator.
Now, by way of exercise, consider this problem: When I input strings with capital letters, capitals are counted as coming before lower-case. For example, here’s a run of my program:
roche@ubuntu$
./stringsort
Enter number of strings:
4
Enter strings:
Xena adroit lefty xylaphone
Xena
adroit
lefty
xylaphone
Quite possibly, this is not what I want. How can I change the program so that capitalization is not taken into account when sorting, although when I print the strings they should have their original capitalization? To make it simple, let’s assume that the only possible capitals would be the first letter. Hopefully you see by now that all this requires is changing the function before
:
int before(char* a, char* b) {
if (a[0] >= 'A' && a[0] <= 'Z') {
a[0] = a[0] + ('a' - 'A');
}
if (b[0] >= 'A' && b[0] <= 'Z') {
b[0] = b[0] + ('a' - 'A');
}
return strcmp(a, b) < 0;
}
In fact, this can be written even more simply if you use the tolower
function from the standard library ctype.h:
int before(char* a, char* b) {
a[0] = tolower(a[0]);
b[0] = tolower(b[0]);
return strcmp(a, b) < 0;
}
But wait, there’s a small problem here. This is what would happen if you ran this program:
roche@ubuntu$
./stringsort
Enter number of strings:
4
Enter strings:
Xena adroit lefty xylaphone
adroit
lefty
xena
xylaphone
Notice what went wrong? The X
in "Xena"
was made lowercase in the before
function, and it stayed that way! This is a consequence of the fact that strings are passed by pointer, and like any other array, changing the array contents within a function affects the original array contents.
So probably we should update the before
function so that it changes the letters back to their original values after doing the comparison, like so:
int before(char* a, char* b) {
char orig_a0 = a[0];
a[0] = tolower(a[0]);
char orig_b0 = b[0];
b[0] = tolower(b[0]);
int result = strcmp(a, b) < 0;
a[0] = orig_a0;
b[0] = orig_b0;
}
An alternative option would be to make copies of a
and b
at the beginning and change the copies rather than changing the original strings. An even simpler approach would be to change the call to strcmp()
to use the strcasecmp()
function, which ignores case in the comparison of strings. However, strcasecmp
is not part of the C language standard so it’s not available on every computer, even though it should be on your Linux lab machines and virtual machines.
3 Search
3.1 Search and Match
Searching for values in arrays is another fundamental operation. The basic format is search(data, size, x)
, where we search for value x
in the array data
of size
elements, and return the index of an element of data
that matches the value x
. If no such element is found, an index of size
may be returned and, since it is not a valid index, the caller of the function can determine that no match was found.
For example, to search in an array of objects of type double
, I’d define the following function:
int search(double* data, int size, double x) {
int i = 0;
while(i < size && data[i] != x) {
i++;
}
return i;
}
As with before
in sorting, we may imagine different ways in which elements are considered to match. But just like with sorting, we might like to leave our basic search
function alone, and encompass what it means for to match
in a separate function of its own. So we might re-write the search
function as:
int search(double* data, int size, double x) {
int i = 0;
while(i < size && !match(data[i], x)) {
i++;
}
return i;
}
And actually having such a match
function could be really useful in this case, because double
computations can suffer from rounding errors. Maybe we’d just like to find a number in the array that’s within 0.1
of x
? Then the match
function could be written as:
int match(double a, double b) {
double diff = a - b;
return diff > -0.1 && diff < 0.1;
}
Tip: It may not seem natural or obvious to return an int equal to the size of the array when a search fails. Why not -1? Why not size-of-the-array + 1? Certainly both of those work, as do many other options. However, experience has shown that returning the array size on fail is a nice way to do it and this approach has become pretty standard. The fact that it is pretty standard is really the most compelling argument of all. In the absence of compelling reasons to do otherwise, shouldn’t we write our code to look like what people expect and are used to seeing?
3.2 Generic programming
What we’ve seen here in both selectionSort
and search
is that (more or less) the same function can be used no matter what type of thing is in the array, and no matter how the elements are being compared.
Code that works this way, where the algorithm stays the same regardless of what type of things you’re working with, is called generic. This is a really important concept, because it allows us as programmers to solve a problem once and then stop worrying about it.
If you’re writing a program and you need to do sorting, by all means copy the selectionSort
code from above and paste it into your program! If you change the type declarations and write a proper before
function according to the task at hand, you’ll be all set.
To be fair, this isn’t entirely a worry-free process because you still have to manually change the types for whatever you’re sorting, but that’s about the best we can get for generic programing in C. Other programming languages have much more sophisticated mechanisms to help you write generic programs, but the underlying concept of re-using the same basic algorithm with different underlying types is the same.
4 Problems
- Printing in binary
- Number of characters to print a number
- Computing Continued Fractions
Write a program that reads in a list of non-negative integers from the user and sorts them by their last digit. Numbers with the same last digit should appear smallest to largest. So, 678 32 67 102 7 18 would appear as 32 102 7 67 18 678.
- Next, try allowing the user to enter a modulus M along with the numbers, and sort the numbers according to their mod M value - smaller mod M values first. Numbers with the same mod M values should appear smallest to largest.
Write a program that reads in a list of 3D points (x,y,z) and prints them out in increasing distance from the origin. The points should be represented as arrays of length 3. Here’s my solution. Below is a sample run of the program:
./sortpoints
How many points?
3
Enter points (x,y,z):
(1,0,1) (1,0,-1.2) (-1,1,-1)
(1,0,1) (1,0,-1.2) (-1,1,-1)
Try the same thing, but sort by x-values, breaking ties by y-values, breaking those ties by z-values.
Try the same thing, but sort in increasing order of distance from a point given by the user.
Write a program that reads in a list of 10 names (first name followed by last name) from the file names.txt and prints them out in the usual order - i.e. alphabetically by last name, using first names to break ties.
Note: You’ll want to represent a person’s name as an array of length 2.
Here’s my solution