Data Structures, Hour by Hour
Data Structures, Hour by Hour

Companion to Robert Lafore · Sams Teach Yourself Data Structures and Algorithms in 24 Hours · 1999

Data Structures, Hour by Hour

Every hour of Lafore's book, retold in plain language: the ideas, the examples, the C++ code, and working versions of the Workshop applets, so you can watch each structure change as you use it.

  • 24 one-hour lessons
  • 5 parts
  • C++ examples
  • 29 Workshop applets to try
  • Quiz answers built in
  1. 01
  2. 02
  3. 03
  4. 04
  5. 05
  6. 06
  7. 07
  8. 08
  9. 09
  10. 10
  11. 11
  12. 12
  13. 13
  14. 14
  15. 15
  16. 16
  17. 17
  18. 18
  19. 19
  20. 20
  21. 21
  22. 22
  23. 23
  24. 24

Part I · FoundationsPart II · Abstract data typesPart III · Recursion & sortingPart IV · TreesPart V · Hashing & choosing

Before Hour 1

Intro Introduction

What the book sets out to teach, who it was written for, what you should bring with you, and how its 24 one-hour lessons fit together. It closes with a short guide to reading this companion.

In this introduction

  • What data structures and algorithms are, and why the book treats them together
  • The four features that set the book apart, starting with its Workshop applets
  • Who the book is for and what you should know before you begin
  • The software you needed in 1999, and what you need today
  • How the five parts and 24 hours are organized, and the conventions used along the way

What This Book Is About

Almost every program keeps some information and does something with it. A data structure is a way of arranging that information in the computer's memory. The book covers the classic ones: arrays, linked lists, stacks, queues, several kinds of trees, and hash tables.

An algorithm is a step-by-step procedure that works on a structure. Typical jobs are putting a new item in, looking an item up, taking one out, and sorting everything into order.

The two ideas belong together. A structure is only useful because of the operations you can run on it, and the speed of each operation depends on how the data is arranged. Looking up one name can take a single step or a million steps, depending on the structure underneath.

callswork onYour programAlgorithmsinsert, search,delete, sortData structurearray, list, tree,hash table
A program hands its work to algorithms, and the algorithms operate on the data structure that holds the data. The structure decides how many steps each operation needs.

Lafore's aim is practical. He wants programmers and students to know these tools well enough to pick the right one, so their programs run faster and are better organized. The book stays light on mathematics and skips formal proofs. To show that one method beats another, it counts steps and draws pictures.

What's Different About This Book

Four features shape the way the book teaches.

Workshop applets. For each major topic, the book's CD carried a small interactive program, a Workshop applet, that draws the structure on screen. You press buttons to insert, delete, search or sort, and watch the data change. Seeing a structure move teaches more than pages of description, which is the old point about a picture being worth a thousand words.

An easy-to-understand approach. Where many textbooks rely on proofs and formulas, this one leans on intuition, everyday analogies and plenty of figures.

C++ sample programs. Every important idea comes with a complete, short program that you can compile, run, and read in one sitting.

Hour-sized chapters. Each chapter is designed to be learned in about an hour, with a quiz at the end to check that it stuck.

Who This Book Is For

Students taking a data structures course can use the book as a friendly companion to a heavier textbook. Programmers who never took such a course, self-taught developers, and anyone who wants a quick, practical grounding will find it a good place to start. It is not written for experts, since it explains everything from the ground up.

What You Need to Know Before You Read This Book

You should have written some programs before. C++ experience is ideal, but a reader who knows C or Java will follow the code easily because the syntax is so similar. You should be comfortable with variables, loops, functions and arrays, and have a rough idea of what a class is. Hour 1 includes a quick refresher on object-oriented programming.

You don't need to know anything about data structures. The mathematics stops at simple algebra, and the one tool beyond it, the logarithm, gets a plain explanation in Hour 3.

The Software You Need to Use This Book

To run the sample programs you need a C++ compiler. The programs are standard console-mode C++, reading and writing plain text with no graphics, so any standard compiler of the day would do, such as Microsoft Visual C++, Borland C++ or the GNU compiler, g++.

To run the Workshop applets you needed a Java-enabled web browser, or the appletviewer program from Sun's Java Development Kit (JDK). Appendix B of the book explained how to set up both.

How This Book Is Organized

The 24 hours are grouped into five parts, followed by three appendixes. Each part builds on the ones before it, so the structures grow more capable as you go.

Part IHours 1–5arrays, sortingPart IIHours 6–10stacks, queues, listsPart IIIHours 11–14recursion, quicksortPart IVHours 15–20search treesPart VHours 21–24hashing, choosingPart VIAppendixes A–C
The book's path, from the humble array in Part I to hash tables and advice on choosing a structure in Part V. Part VI holds the quiz answers and reference material.

Part I, Introducing Data Structures and Algorithms (Hours 1 to 5). Hour 1 surveys the field and refreshes your C++. Hour 2 covers arrays and wraps one in a class. Hour 3 keeps the array in order for binary search, which leads to logarithms and Big O notation. Hours 4 and 5 teach two simple sorts, the bubble sort and the insertion sort.

Part II, Abstract Data Types (Hours 6 to 10). Hour 6 introduces stacks and Hour 7 queues and priority queues, structures that limit which item you can reach. Hour 8 brings in linked lists, Hour 9 rebuilds stacks and queues from lists and explains abstract data types, and Hour 10 covers sorted and doubly linked lists.

Part III, Recursion and Quicksort (Hours 11 to 14). Hour 11 explains recursion, a function calling itself. Hour 12 applies it to the Towers of Hanoi and to mergesort. Hour 13 presents quicksort, and Hour 14 keeps it fast on awkward data.

Part IV, Trees (Hours 15 to 20). Hours 15 and 16 cover binary search trees. Hours 17 and 18 explain red-black trees, which keep themselves balanced. Hours 19 and 20 describe 2-3-4 trees, whose nodes hold several items, and their close tie to red-black trees.

Part V, Hash Tables (Hours 21 to 24). Hour 21 introduces hashing and linear probing, Hour 22 adds quadratic probing and double hashing, and Hour 23 covers separate chaining and hash functions. Hour 24, "When to Use What," turns the whole book into advice for choosing a structure.

Part VI, Appendixes. Appendix A answers every quiz, Appendix B explains how to run the applets and sample programs, and Appendix C suggests further reading.

Enjoy Yourself!

Data structures have a reputation as a dry and difficult subject, and Lafore sets out to prove otherwise. He invites you to treat the book as a place to play, where you run the applets, feed them odd inputs, and see what happens.

There is real satisfaction in watching a tree rebalance itself or a quicksort carve an array into pieces. Once the pictures make sense, the code usually does too.

Conventions Used in This Book

The book sets code, such as class and function names, in a monospace font. New terms are flagged and defined where they first appear. Short notes, tips and cautions sit in boxes beside the main text, and "To Do" boxes give numbered steps for working with the applets.

Every hour ends the same way, with a summary, a Q&A section, and a Workshop quiz whose answers are in Appendix A. This companion keeps the same habits, with a few changes for the web. Its boxes carry these labels:

The note boxes in this companion
LabelWhat the box holds
New termThe definition of a term, where it first appears
Just a minuteA remark to pause on, or a clarification
Time saverA shortcut or rule of thumb
CautionA common mistake or trap
To doNumbered steps to try in a Workshop panel
Beyond the bookExtra context that is not in the book, such as modern C++ practice

Quiz answers are hidden under an "Answer" toggle at the end of each hour, so you can test yourself before you look. The Workshop panels are JavaScript re-creations of the book's applets, with similar buttons and displays.

How to Use This Companion

Read the hours in order, because later hours lean on earlier ones. Big O notation from Hour 3, for example, appears in every hour after it.

When a Workshop panel appears, stop and use it. Follow the To do steps, then try inputs of your own; watching the counts of comparisons and moves is the quickest way to feel why one structure beats another. At the end of each hour, answer the quiz before opening the answers, then press the button at the end of the hour to mark it as read.

The C++ listings keep the book's class and function names, so you can match them to the original. The code itself is written fresh for this companion in the book's style, and it compiles with a modern compiler.

1

Part I · Introducing Data Structures and Algorithms

Hour 1 Overview of Data Structures and Algorithms

Before studying any single structure, it helps to survey the whole toolbox. This hour covers what data structures are for, what each one is good and bad at, which jobs algorithms perform, and the C++ you'll use to build them.

In this hour you'll learn

  • The three kinds of jobs that data structures and algorithms do
  • The strengths and weaknesses of each structure in the book
  • The basic operations: insertion, searching, deletion, iteration and sorting
  • What databases, records, fields and keys are
  • How C++ classes, objects, constructors and access control work
  • The newer C++ features the book relies on, and where all this fits in software engineering

Some Uses for Data Structures and Algorithms

Nearly every serious program has to hold data and work with it. The way that data is arranged can make the difference between a program that answers instantly and one that grinds away for minutes. The situations where data structures help fall roughly into three groups.

Data structuresand algorithmsReal-world storageaddresses, inventoryProgrammer's toolsstacks, queuesModelingbank lines, roads
The three kinds of jobs data structures and algorithms do, with examples of each.

Real-World Data Storage

Many structures store information about things outside the computer, such as employees in a personnel file, parts in an inventory, or stamps in a collector's catalog.

Lafore's example is an address book kept on 3×5 index cards, each holding one person's name, address and phone number. Imagine moving the card box into a computer, with 1,000 cards in it. You'd have to arrange the cards in memory, and you'd need ways to add a card, find the card for a given name, and remove a card, all without wading through every card each time.

Those three jobs, insert, find and delete, come up again and again. For ten cards any arrangement is fast enough, but for thousands or millions the arrangement decides everything. Most of the structures in this book are different answers to this storage problem.

Programmer's Tools

Some structures never hold the user's data. The program uses them internally as scaffolding, and the user never knows they exist. Stacks, queues and priority queues, the subject of Part II, are often used this way. In Hour 6, for example, a stack checks that every opening bracket in an expression has a matching closing one.

Modeling

A few structures mirror a real situation directly. A queue behaves just like customers waiting for a bank teller or cars lined up at a toll booth, where whoever arrived first is served first, so a program that simulates the bank can use a queue to stand for the line. Graphs, which this book doesn't cover, model networks such as airline routes or city roads.

Overview of Data Structures

The book covers nine structures. Each one makes some operations fast at the cost of making others slow or complicated, and no structure wins at everything. The table below previews the trade-offs, and every row gets one or more hours of its own later on.

Characteristics of data structures
StructureAdvantagesDisadvantagesCovered in
ArrayQuick insertion; very fast access if you know the indexSlow search; slow deletion; fixed sizeHour 2
Ordered arrayQuicker search than an unsorted arraySlow insertion and deletion; fixed sizeHour 3
StackLast-in, first-out accessSlow access to other itemsHour 6
QueueFirst-in, first-out accessSlow access to other itemsHour 7
Linked listQuick insertion; quick deletionSlow searchHours 8 to 10
Binary treeQuick search, insertion and deletion, if the tree stays balancedDeletion algorithm is complexHours 15 and 16
Red-black treeQuick search, insertion and deletion; always balancedComplexHours 17 and 18
2-3-4 treeQuick search, insertion and deletion; always balanced; similar trees suit disk storageComplexHours 19 and 20
Hash tableVery fast access if the key is known; fast insertionSlow deletion; slow access if the key is not known; inefficient use of memoryHours 21 to 23

Read the table as a map of what lies ahead. The array is quick to add to but slow to search, and keeping it sorted trades faster searching for slower insertion. Stacks and queues deliberately let you reach only one item at a time.

Linked lists make insertion and deletion quick. Trees make nearly everything quick while they stay balanced, and red-black and 2-3-4 trees guarantee that balance at the price of more complex code. Hash tables are fastest of all when you know the key, and Hour 24 returns to every structure to help you choose.

Overview of Algorithms

Most algorithms in the book belong to one particular structure, since finding an item in an array has little in common with finding one in a tree. Across all the structures, though, the same basic jobs keep coming up.

  • Insertion puts a new data item into the structure.
  • Searching finds a specified item, usually by its key.
  • Deletion removes a specified item.

You'll also need to iterate through a structure, visiting each item in turn, for example to display them all. And you'll need to sort items into order, a job that earns several hours of its own (Hours 4, 5 and 12 to 14).

Recursion, in which a function calls itself, is a technique for writing algorithms rather than an operation. It is the key to the fastest sorts and to working with trees, and Part III is devoted to it.

For every algorithm, the book asks how much work it takes. Work is usually counted in comparisons (checking one key against another) and moves (copying an item from one place to another), and it almost always depends on N, the number of items.

Some Initial Definitions

A few words appear so often in the rest of the book that it pays to define them now. The address book makes a good running example.

Database

A database is all the data you are working with in a particular situation. The box of address cards is a database, and so is a company's file of employee records. In this book the word simply means the collection of data, with no suggestion of a special database program.

Record

A database is divided into records, the units you store and retrieve. Each index card is one record, holding everything the address book knows about one person. In an employee file, each record describes one employee.

Field

Each record is divided into fields, one for each kind of information. An address card has a name field, an address field and a phone number field. In a C++ program a record is usually an object, and each field is one of its data members, such as a string lastName or an int age.

Key

To find a record, or to sort a set of them, you pick one field to work with, and that field is the key. For the address book the natural key is the last name, since that's how you look people up and how you would file the cards.

The particular value you're looking for, such as "Smith," is the search key. A search compares the search key with the key field of each record it examines until it finds a match or runs out of records.

made ofmade ofone is thecompared withDatabasethe address bookRecordone cardFieldsname, address, phoneKey fieldlast nameSearch keythe value Smith
The address book as a database. It is made of records (cards), each record is made of fields, and one field, the last name, serves as the key. A search compares a search key such as Smith with the key field of each record.

A Quick Introduction to Object-Oriented Programming

Every program in the book is built from classes, so this section reviews the object-oriented ideas you'll need. If you use C++ classes every day, you can skim it.

Problems with Procedural Languages

Object-oriented programming (OOP) was invented to fix two weaknesses of procedural languages such as C and Pascal, in which a program is a collection of functions and variables.

The first weakness is poor modeling of the real world. Picture a program that controls the heating in a house. It needs variables for the current and desired temperatures, and functions that switch the furnace on and off. In a procedural language these are separate items, possibly scattered through the program, and nothing says they belong together as one thermostat. With three thermostats, it's up to you to keep track of which function acts on which set of variables.

The second weakness is crude organizational units. A procedural program is built from only functions and global data, which is too little structure once a program grows to thousands of lines. Worse, any function can change global data, so a mistake in one corner can corrupt data used in another.

Objects in a Nutshell

An object bundles data together with the functions that act on that data. A thermostat object contains the data items currentTemp and desiredTemp along with the functions furnace_on() and furnace_off(). The data items are called data members, and the functions are called member functions. Now the language itself records that these things belong together.

A class is the specification for a kind of object, a blueprint that says what data and functions every object of that kind will have. Writing the class doesn't create any objects; it only describes them. Each object you later create from the class is an instance of it. Here is the outline of a thermostat class, with the furnace-control details left out.

thermostat The outline of a class
class thermostat {
private:
    // each thermostat keeps its own two temperatures
    float currentTemp;
    float desiredTemp;

public:
    // the jobs a thermostat knows how to do
    void furnace_on() {
        // statements that switch the furnace on
    }

    void furnace_off() {
        // statements that switch the furnace off
    }
};

The keyword class is followed by the class name and a pair of braces holding the members, and a semicolon ends the whole thing. The labels private and public control who may use each member, as you'll see shortly. Once the class exists, you create objects from it just as you create variables of a built-in type, and then you tell them what to do.

thermostat Creating objects and calling their member functions
int main() {
    // two thermostat objects, each with its own temperatures
    thermostat therm1, therm2;

    // each call works on one particular object
    therm1.furnace_on();
    therm2.furnace_off();

    // an object made with new is reached through a pointer
    thermostat* pTherm = new thermostat;
    pTherm->furnace_on();
    delete pTherm;
    return 0;
}

Each object gets its own copy of the data members, so therm1 and therm2 can hold different temperatures. To call a member function, you write the object's name, a dot, and the function. The dot operator ties the call to one object, so therm2.furnace_off() switches off the furnace that therm2 controls and leaves therm1 alone.

An object made with new is reached through a pointer, and the arrow operator, ->, replaces the dot. The book's pointer names start with a p, as in pTherm.

instanceinstanceclass thermostatthe blueprinttherm1currentTemp = 64desiredTemp = 68therm2currentTemp = 71desiredTemp = 68
One class, many objects. The class describes what every thermostat has, and each object made from it holds its own values.

A Runnable Object-Oriented Program

The thermostat outline can't do anything, because its functions are empty. The book's first complete program, bank.cpp, models something just as familiar, a bank account. Its class, BankAccount, holds one data member, the balance, and offers three services: deposit money, withdraw money, and display the balance.

bank.cpp A bank account as a class
// bank.cpp
// A bank account modeled as a C++ class
#include <iostream>
using namespace std;

class BankAccount {
private:
    // the account's only data: the money in it
    double balance;

public:
    // constructor: runs whenever an account is created
    BankAccount(double openingBalance) : balance(openingBalance) {
    }

    // add money to the account
    void deposit(double amount) {
        balance += amount;
    }

    // take money out of the account
    void withdraw(double amount) {
        balance -= amount;
    }

    // show the current balance
    void display() {
        cout << "balance=" << balance << endl;
    }
};

int main() {
    // open an account with $100 in it
    BankAccount ba1(100.00);

    cout << "Before transactions, ";
    ba1.display();

    ba1.deposit(74.35);
    ba1.withdraw(20.00);

    cout << "After transactions, ";
    ba1.display();
    return 0;
}

In main(), the statement BankAccount ba1(100.00); creates an account object named ba1 with an opening balance of $100. The program prints a label and calls ba1.display(). Then it deposits $74.35 and withdraws $20.00, each time by calling a member function of ba1, and displays the balance again. Since 100 + 74.35 − 20 = 154.35, the output is:

Before transactions, balance=100
After transactions, balance=154.35

(C++ prints 100 rather than 100.00 because its default format drops trailing zeros.) Notice that main() never touches balance directly. It asks the object to deposit, withdraw or display, and the object's own functions do the arithmetic.

Constructors

A constructor is a special member function that runs automatically when an object is created, to give the object's data sensible starting values. It has exactly the same name as its class, and it has no return type, not even void.

In bank.cpp the constructor takes the opening balance as an argument. The part after the colon, : balance(openingBalance), is an initializer list, which copies the argument into the data member, so the body has nothing left to do. You never call a constructor by name. Defining ba1 calls it for you, passing 100.00; without it, balance would start out holding whatever value happened to be in memory.

public and private

The data member balance sits in the class's private section. Private members can be read or changed only by the class's own member functions. If main() tried to write ba1.balance = 1000000;, the compiler would refuse. This protection is called data hiding, and it guards the data against careless or accidental changes from outside the class.

The member functions sit in the public section, so any code with a BankAccount object can call them. Together, the public functions form the class's interface, the services the outside world may use. The usual pattern, and the book's, is private data with public functions. Anyone who wants to change the balance must go through deposit() or withdraw(), which is where a real bank would add checks, such as refusing an overdraft.

Inheritance and Polymorphism

Two more OOP ideas complete the picture, though the book uses them very little. Inheritance lets you create a derived class from an existing base class. The derived class gets all the base class's data and functions and can add its own. You could derive a savings account class from BankAccount, for example, and add a function that pays interest.

Polymorphism lets you treat objects of different derived classes in the same way. You call the same function on each object, and each responds in the manner of its own class. In C++ this works through virtual functions, usually called through pointers to the base class.

New C++ Features

The book's programs rely on parts of Standard C++ that older courses and textbooks often skip. If you learned C++ from one of them, three features may be new to you.

The string Class

Older C++ code stores text in arrays of char and handles it with functions such as strcpy() and strcmp(), a clumsy style that makes it easy to write past the end of the array. Standard C++ offers a string class instead. After including <string>, you can write string s1 = "cat"; and treat the string much like a built-in type.

The + operator joins strings, so s1 + s2 glues the second onto the end of the first. The operators == and < compare them, with "less than" meaning "comes first alphabetically" (when both words use the same capitalization), and s1.length() reports the number of characters. A string grows as needed, and comparing strings with == is how the book's programs search by name.

The vector Class

The vector class belongs to the Standard Template Library (STL), a collection of ready-made containers and algorithms that is part of Standard C++. A vector behaves much like an array, and you reach its elements with square brackets, as in v[j]. Unlike an array, it knows its own size, which v.size() reports, it manages its own memory, and it can grow.

You include <vector> and name the element type in angle brackets. vector<double> v(100); creates 100 double values. You can also create an empty vector and size it later with v.resize(100);, as the book's classes do in their constructors, and v.push_back(value) adds an element at the end. Because of this, the array classes in Hour 2 and Hour 3 can take their size as a constructor argument while the program runs, which a plain C++ array can't do.

New-Style Headers and Namespaces

Standard C++ also renamed the library header files. You write #include <iostream> instead of the older #include <iostream.h>. Everything in the standard library now lives in a namespace called std.

The line using namespace std; near the top of a file lets you write cout, string and vector without putting std:: in front of each one, and every program in the book starts this way. Here is a short program that tries out both classes.

features.cpp The string and vector classes at work
// features.cpp
// A quick tour of the string and vector classes
#include <iostream>
#include <string>
#include <vector>
using namespace std;

int main() {
    // strings join with + and compare with == and <
    string s1 = "cat";
    string s2 = "fish";
    string s3 = s1 + s2;
    cout << s3 << " has " << s3.length() << " letters" << endl;
    if (s1 < s2)
        cout << s1 << " comes before " << s2 << endl;

    // a vector sized in advance works like an array
    vector<double> v(5);
    for (int j = 0; j < 5; j++)
        v[j] = j * 1.5;

    // push_back adds one more cell at the end
    v.push_back(99.0);
    cout << "The vector now holds " << v.size() << " numbers" << endl;
    return 0;
}
catfish has 7 letters
cat comes before fish
The vector now holds 6 numbers

The first half builds "catfish" by joining two strings, reports its seven letters, and confirms that "cat" sorts before "fish." The second half creates a five-cell vector, fills it with a loop exactly as you would fill an array, and then calls push_back() to add a sixth value, so size() reports 6.

Software Engineering

Software engineering is the study of how to create large programs. It covers a program's whole life cycle, from the first idea to years of later upkeep, and the stages are usually described like this:

  1. Specification, or analysis, works out what the program must do.
  2. Design decides how the program will be organized, including its classes and data structures.
  3. Coding writes the program.
  4. Testing finds and fixes errors.
  5. Maintenance keeps the program working and adapts it after release, often for years.
new needsSpecificationwhat must it do?Designclasses, structuresCodingalgorithms in C++Testingfind the bugsMaintenancefix and extend
The software life cycle. Data structures and algorithms do most of their work in design and coding (highlighted), and good choices there make testing and maintenance easier.

Data structures and algorithms matter most in design and coding. Choosing a structure that suits the job is a design decision, implementing it well is a coding task, and together they decide whether the finished program is fast or slow.

The benefits spread to the later stages too. Well-chosen, cleanly separated classes are easier to test, because each can be checked on its own, and easier to maintain, because a change inside one class doesn't ripple through the rest. Software engineering is a big subject with many books of its own, and this book deals only with the parts that concern data structures and algorithms.

Summary

  • Data structures and algorithms serve three purposes: storing real-world data, acting as tools inside programs, and modeling real situations.
  • Each structure makes some operations fast and others slow, and none is best for everything. Arrays, ordered arrays, stacks, queues, linked lists, binary trees, red-black trees, 2-3-4 trees and hash tables each get hours of their own.
  • The basic operations are insertion, searching and deletion. Iterating through every item and sorting are also common.
  • A database is all the data in a situation. It is divided into records, and each record is divided into fields.
  • The key is the field you search or sort by. The search key is the particular value you're looking for.
  • An object bundles data members with the member functions that act on them. A class is the blueprint, and objects are its instances.
  • A constructor has the class's name and no return type, and it runs automatically to initialize each new object.
  • Private data can be reached only through the class's own functions. The public functions form the class's interface.
  • The book uses Standard C++: the string class, the STL's vector, and new-style headers with using namespace std;.
  • Software engineering covers a program's whole life cycle, and data structures and algorithms matter most in its design and coding stages.

Q&A

Standard C++ already provides containers such as vector. Why learn how data structures work inside?
Because you can't choose well among tools you don't understand. Knowing how each structure works tells you which library container fits a job, how to use it efficiently, and what each operation will cost. Sometimes no ready-made container fits your problem, and then you need to build your own.
Do I have to know C++ to get anything out of this book?
No. The structures and algorithms are the same in every language. A reader who knows Java or C will follow the code without trouble, and the pictures and applets need no language at all. C++ is simply the vehicle.
What if my compiler doesn't have the string and vector classes?
Then it predates Standard C++, and the best fix is a newer compiler, since every modern one includes both classes. If you're stuck with an old one, you can replace the vectors with ordinary arrays and the strings with char arrays, at the cost of messier code.
Is an algorithm the same thing as a program?
An algorithm is the procedure itself, the idea of how to do a job, and you can describe it in words, in a diagram or in any programming language. A program expresses one or more algorithms in a particular language. Binary search, for example, is one algorithm that appears in countless programs.

Quiz

  1. What are the three general kinds of uses for data structures and algorithms?

    Answer

    Storing real-world data (such as an address book or personnel records), serving as programmer's tools inside a program (such as stacks and queues), and modeling real situations (such as a queue of customers at a bank).

  2. In an address book database, what is the key, and what is a search key?

    Answer

    The key is the field used to search for or sort the records, usually the last name. A search key is the particular value you're looking for, such as "Smith."

  3. What is the difference between a class and an object?

    Answer

    A class is a specification, or blueprint, describing the data and functions that objects of its kind will have. An object is an actual instance created from the class, with its own copy of the data. One class can produce many objects.

  4. What does a constructor do, and how can you recognize one?

    Answer

    It initializes a new object's data, and it runs automatically when the object is created. It has the same name as its class and no return type.

  5. What does it mean for a data member to be private?

    Answer

    Only the class's own member functions can read or change it. Code outside the class must go through the public member functions, which protects the data from accidental changes.

  6. Why do the book's array classes use a vector instead of a plain C++ array?

    Answer

    A vector's size can be chosen while the program runs (with resize() in the constructor), it knows its own size, it manages its own memory, and it can grow with push_back(). A plain array's size is fixed when the program is compiled.

2

Part I · Introducing Data Structures and Algorithms

Hour 2 Arrays

The array is the simplest data structure and the one programmers use most. This hour measures how fast an unsorted array can insert, search and delete, then moves the array into a C++ class and shows why a well-designed interface matters.

In this hour you'll learn

  • How insertion, searching and deletion work in an unsorted array, and what each costs
  • What changes when duplicate keys are allowed
  • How to create, fill, search and shrink a plain C++ array
  • How to move an array into a class with a bare-bones interface
  • How a better interface puts the work inside the class, and why that abstraction pays off

The Array Workshop Applet

Suppose you coach a youth-league baseball team and want to take attendance at practice on a laptop. Players turn up one at a time. When a player arrives, you type in the number on the back of the player's jersey. When a parent asks whether a certain player made it, you check the list, and when a player leaves early, you take the number off.

This little program needs exactly the three basic operations from Hour 1: insertion, searching and deletion. The jersey number is the key, and each player is a record with just that one field.

The simplest structure for the job is an array, and the book's Array Workshop applet shows one on the screen. It draws the array as a row of cells, each labeled with its index number and able to hold one key. Buttons let you create a new array (New, which asks for a size and whether duplicate keys are allowed), fill it with random keys (Fill), and insert, find or delete a key typed into a number field (Ins, Find, Del).

Our re-creation below works the same way and counts the comparisons and moves that each operation makes. Try each button as you read the rest of this section.

Insertion

When a new player arrives, the applet puts the number into the first empty cell, just past the last item. It knows exactly where that is because it keeps a count of the items. If the array holds 10 items, they occupy cells 0 through 9, so the new item goes into cell 10 and the count becomes 11.

That makes insertion very fast. There is no searching and no comparing, just one move to copy the new item into place, and the time is the same whether the array holds 10 items or 10,000.

8406111527332643851164975383295710111213new: 57nElems
Inserting 57. Ten players occupy cells 0 to 9, so the count, nElems, is 10, which is also the index of the first empty cell. The new key goes there in one move with no comparisons, and nElems becomes 11.

Searching

To check whether a player is present, you type the number and press Find. The algorithm has no idea where the number might be, so it starts at cell 0 and compares each key with the one you typed, moving right one cell at a time, until it finds a match or runs out of items.

If the player is there, the match could be in the first cell or the last, and every position is equally likely. On average the search looks at half the items before it succeeds, so for N items it needs about N/2 comparisons. With 20 players that's 10 comparisons on average; with 20,000 records it's 10,000.

If the player isn't there, the search can't stop early. It must compare all N items before it can report failure.

8406111527332643851164975383291011j
A linear search for 26. Cells 0 to 3 were compared and didn't match (faded), and the fifth comparison, with j at index 4, finds the key.

Deletion

Deleting a player takes two stages. First the algorithm must find the item, which costs about N/2 comparisons on average, just like a search. Then it has to deal with the empty cell the item leaves behind.

The applet's algorithms assume the array has no holes. The items sit in consecutive cells starting at 0, with no empty cells between them. That is what lets insertion go straight to the cell after the last item, and what lets a search stop at the last item instead of checking every cell in the array.

84061115232643851164975383291011hole
What deletion must avoid. If deleting 73 simply emptied cell 3, the array would have a hole, and insertion and searching could no longer assume that the items fill cells 0 to nElems − 1.

So after finding the item, deletion closes the gap. Every item above the deleted one moves down one cell, starting with the item just above the gap. If the deleted item was near the start, nearly everything moves; if it was near the end, hardly anything does.

On average about N/2 items move. Deletion therefore costs about N/2 comparisons plus about N/2 moves, which makes it the slowest of the three operations.

The Duplicates Issue

When you design a storage structure, you have to decide whether two items may share the same key. Jersey numbers are unique, so the attendance list never needs duplicates. If the key were a last name, though, two brothers named Garcia could easily be on the same team. The book's applet lets you choose when you press New, and allowing duplicates changes the costs.

  • Searching. Without duplicates, a search stops at the first match, after N/2 comparisons on average. With duplicates, finding every item with a given key means checking all N cells, since another match could be anywhere.
  • Insertion. This stays one move with no comparisons either way, because the new item always goes in the first empty cell. There is one catch. If duplicates are forbidden and a user might type a key that's already present, the program must search before inserting, which costs about N/2 comparisons when the key is found and all N to prove that it's absent.
  • Deletion. Deleting every copy of a key means examining all N cells, and each copy removed forces more items to shift, so the moves usually add up to more than N/2.
Duplicates allowed versus not allowed, for an array of N items
OperationNo duplicatesDuplicates OK
SearchN/2 comparisonsN comparisons
InsertionNo comparisons, one moveNo comparisons, one move
DeletionN/2 comparisons, N/2 movesN comparisons, more than N/2 moves

Not Too Swift

Playing with the applet makes the verdict clear. Insertion is fast, but searching and deletion are slow, because both have to plow through about half the array. For a team of 15 players nobody would notice. For a file of 100,000 customers, an average of 50,000 comparisons per lookup starts to hurt.

Much of the rest of the book is a hunt for structures that do better. An ordered array (Hour 3) makes searching far faster. Linked lists (Hour 8) do away with the shifting, trees (Hour 15) speed up all three operations, and hash tables (Hour 21) can find an item almost instantly.

An Array Example

Before wrapping an array in a class, it helps to see a plain C++ array doing the same jobs. First, a quick review of how C++ arrays work.

Creating an Array

You declare an array by giving the type of its elements, its name, and its size in square brackets. int arr[100]; sets aside room for 100 integers. The size must be a constant that is known when the program is compiled, and it can never change afterward. That fixed size is one of the array's big limitations, as you'll see at the end of Hour 3.

Accessing Array Elements

You reach an element by putting its index in square brackets, as in arr[j]. Indexes start at 0, so the 100 cells of arr are numbered 0 through 99. Asking for arr[100] is a mistake that the compiler won't catch, and the running program won't catch it either. It simply reads or overwrites whatever memory lies beyond the array, which can cause baffling bugs or a crash.

Initialization

You can fill an array when you create it by listing values in braces, as in int intArr[] = {0, 3, 6, 9, 12, 15};. If you leave the brackets empty, the compiler makes the array just big enough for the list, six cells in this case.

The array.cpp Program

The program array.cpp puts all of this to work. It inserts ten items, displays them, searches for one, deletes another, and displays what's left, and everything happens inside main(). In our version the ten keys start out in an initialized array, and a loop inserts them one at a time into the working array, arr.

array.cpp A plain array handled entirely in main()
// array.cpp
// Plain C++ array: insert, display, search and delete, all in main()
#include <iostream>
using namespace std;

int main() {
    // the ten keys, in the order the players arrive
    int arrivals[] = {77, 99, 44, 55, 22, 88, 11, 0, 66, 33};

    // the array itself, and a count of the cells in use
    int arr[100];
    int nElems = 0;
    int j;

    // insertion: each new item goes into the first empty cell
    for (j = 0; j < 10; j++) {
        arr[nElems] = arrivals[j];
        nElems++;
    }

    // display every item
    for (j = 0; j < nElems; j++)
        cout << arr[j] << " ";
    cout << endl;

    // linear search for 66
    int searchKey = 66;
    for (j = 0; j < nElems; j++)
        if (arr[j] == searchKey)
            break;
    if (j == nElems)
        cout << "Can't find " << searchKey << endl;
    else
        cout << "Found " << searchKey << endl;

    // deletion: find 55, then slide the higher items down
    searchKey = 55;
    for (j = 0; j < nElems; j++)
        if (arr[j] == searchKey)
            break;
    if (j < nElems) {
        for (int k = j; k < nElems - 1; k++)
            arr[k] = arr[k + 1];
        nElems--;
    }

    // display what's left
    for (j = 0; j < nElems; j++)
        cout << arr[j] << " ";
    cout << endl;
    return 0;
}

The variable nElems is the heart of the program. It counts the items, and it's also the index of the first empty cell, since the items fill cells 0 through nElems − 1. That's why insertion is just arr[nElems] = value followed by nElems++.

The search loop compares each item with searchKey and uses break to leave as soon as it finds a match. When the loop ends there are only two possibilities. Either it broke out early and j holds the index of the match, or it ran all the way to the end and j equals nElems. The test j == nElems tells the two apart.

Deletion begins with the same search, this time for 55, which it finds at index 3. The second loop then closes the gap by copying the item at k + 1 into cell k, for each k from the hole upward. It stops when k reaches nElems − 1, so its last copy moves the final item into the cell below it. Then nElems drops by one.

77099144255322488511607668339j
Deleting 55. The search stops with j at index 3. Then 22, 88, 11, 0, 66 and 33 each move one cell to the left, in that order. That's six moves, and nine items remain.

Our version also checks that the search succeeded before it shifts anything, so asking it to delete a missing key does no harm. Here is the output:

77 99 44 55 22 88 11 0 66 33
Found 66
77 99 44 22 88 11 0 66 33

The program shows the ten items, reports the successful search for 66, and then shows the array after 55 has gone. The items that followed 55 kept their order; each simply slid one cell to the left.

Dividing a Program into Classes

array.cpp works, but it's organized like a procedural program. The data (the array and its count) and every operation on it are mixed together in one long main(). In a program of ten thousand lines that used the array in dozens of places, search loops and shifting loops would be scattered everywhere, and any one of them could damage the array.

The object-oriented cure from Hour 1 is to put the data into a class, together with the functions that act on it. The book takes this in two steps. The first, lowArray.cpp, moves the array into a class called LowArray but gives that class only the most basic abilities.

lowArray.cpp The LowArray class
// lowArray.cpp
// The array moves into a class with a bare-bones interface
#include <iostream>
#include <vector>
using namespace std;

class LowArray {
private:
    // the storage is hidden inside the class
    vector<double> v;

public:
    // constructor: make room for max items
    LowArray(int max) {
        v.resize(max);
    }

    // put value into cell number index
    void setElem(int index, double value) {
        v[index] = value;
    }

    // hand back the value in cell number index
    double getElem(int index) {
        return v[index];
    }
};

The array is now a private vector<double> named v, so no code outside the class can reach it directly. The constructor takes the size you want and calls resize() to make the vector that big. After that there are just two member functions. setElem() stores a value at a given index, and getElem() returns the value at a given index.

The client still does all the real work, as the main() function shows.

lowArray.cpp main() still does the searching and shifting
int main() {
    LowArray arr(100);
    int nElems = 0;
    int j;

    // main decides where each item goes
    double arrivals[] = {77, 99, 44, 55, 22, 88, 11, 0, 66, 33};
    for (j = 0; j < 10; j++) {
        arr.setElem(nElems, arrivals[j]);
        nElems++;
    }
    for (j = 0; j < nElems; j++)
        cout << arr.getElem(j) << " ";
    cout << endl;

    // main runs the linear search itself
    double searchKey = 26;
    for (j = 0; j < nElems; j++)
        if (arr.getElem(j) == searchKey)
            break;
    if (j == nElems)
        cout << "Can't find " << searchKey << endl;
    else
        cout << "Found " << searchKey << endl;

    // main shifts the items after a deletion, one cell at a time
    searchKey = 55;
    for (j = 0; j < nElems; j++)
        if (arr.getElem(j) == searchKey)
            break;
    if (j < nElems) {
        for (int k = j; k < nElems - 1; k++)
            arr.setElem(k, arr.getElem(k + 1));
        nElems--;
    }
    for (j = 0; j < nElems; j++)
        cout << arr.getElem(j) << " ";
    cout << endl;
    return 0;
}

These are the loops from array.cpp, with arr[j] replaced by arr.getElem(j) and assignments replaced by setElem() calls. main() still keeps nElems, still decides where each item goes, still runs the linear search, and still does the shifting. Our version searches for 26, which isn't there:

77 99 44 55 22 88 11 0 66 33
Can't find 26
77 99 44 22 88 11 0 66 33

The one real gain is that the data is now hidden. The only way to touch the vector is through two member functions, which could later be changed to check every index, for example. But the interface is so primitive that LowArray is little more than an array with a clumsier syntax.

Class Interfaces

A class's interface is the set of public member functions its clients can call. The interface decides how easy the class is to use, and how much its clients need to know about its insides. LowArray shows what goes wrong when the interface is too thin.

Not So Convenient

With LowArray, every client must know that items are packed from index 0, keep its own count, write its own search loop, and remember to shift items after a deletion, in the right direction and stopping at the right place. Two clients might do these things in two different ways, and a client that forgot to shift would silently leave a hole. The interface hands over all of the responsibility and none of the help.

Who's Responsible for What?

A better division of labor asks the class to manage its own data. The client should only have to say what it wants, such as "insert this," "is this here?" or "remove that," and the class should work out how. That means the item count and all three algorithms move out of main() and into the class. The result is the book's HighArray class.

The highArray.cpp Example

highArray.cpp A class that does the work itself
// highArray.cpp
// An array class with a convenient interface
#include <iostream>
#include <vector>
using namespace std;

class HighArray {
private:
    vector<double> v;
    // how many cells are in use
    int nElems;

public:
    HighArray(int max) : nElems(0) {
        v.resize(max);
    }

    // linear search: true if searchKey is in the array
    bool find(double searchKey) {
        for (int j = 0; j < nElems; j++)
            if (v[j] == searchKey)
                return true;
        return false;
    }

    // put a new item into the first empty cell
    void insert(double value) {
        v[nElems] = value;
        nElems++;
    }

    // delete one item; false if it wasn't there
    bool remove(double value) {
        int j;
        for (j = 0; j < nElems; j++)
            if (v[j] == value)
                break;
        if (j == nElems)
            return false;

        // close the gap: each higher item moves down one cell
        for (int k = j; k < nElems - 1; k++)
            v[k] = v[k + 1];
        nElems--;
        return true;
    }

    void display() {
        for (int j = 0; j < nElems; j++)
            cout << v[j] << " ";
        cout << endl;
    }
};

The class now owns both the vector and the count, nElems, which the constructor sets to zero. Its four public functions do everything main() used to do.

  • insert() puts the new value in cell nElems and adds one to the count.
  • find() runs a linear search. It returns true from inside the loop the moment it sees a match, and if the loop finishes, the key wasn't there.
  • remove() searches for the value and returns false if it can't find it. Otherwise it shifts the higher items down, reduces the count, and returns true.
  • display() prints every item on one line.

Look closely at the shifting loop in remove(). It runs while k < nElems - 1, so its last pass copies v[nElems-1], the final item, into the cell below. If it ran while k < nElems, its last pass would read v[nElems], a cell beyond the last item. Usually that junk would land in a cell that no longer counts, so the mistake could go unnoticed, but when the array is full the read runs off the end of the vector.

Here is the new client:

highArray.cpp main() just asks
int main() {
    HighArray arr(100);

    arr.insert(77);  arr.insert(99);  arr.insert(44);
    arr.insert(55);  arr.insert(22);  arr.insert(88);
    arr.insert(11);  arr.insert(0);   arr.insert(66);
    arr.insert(33);
    arr.display();

    double searchKey = 35;
    if (arr.find(searchKey))
        cout << "Found " << searchKey << endl;
    else
        cout << "Can't find " << searchKey << endl;

    arr.remove(0);
    arr.remove(55);
    arr.remove(99);
    arr.display();
    return 0;
}
77 99 44 55 22 88 11 0 66 33
Can't find 35
77 44 22 88 11 66 33

The search for 35 fails. After 0, 55 and 99 are removed, seven items remain, still in their original relative order.

The User's Life Made Easier

Compare the two versions of main(). The LowArray client was full of loops, index arithmetic and bookkeeping. The HighArray client reads almost like plain English: insert these numbers, display them, look for 35, remove three numbers, display again.

There's no nElems in sight, and main() has no way to leave a hole in the array, because it can reach the array only through functions that keep it tidy. The work didn't disappear, of course; the same loops still run. But they're written once, inside the class, where they can be tested once and trusted wherever the class is used.

Abstraction

The client of HighArray knows what the class does. It stores numbers and can insert, find, remove and display them. The client doesn't know how the class does these things, and doesn't need to. It can't tell whether the numbers live in a vector or somewhere else, or which search method find() uses.

Separating what from how is called abstraction, and it's one of the most useful ideas in programming. Because the client depends only on the interface, you could rewrite HighArray to store its data differently or to search faster, and every client would keep working unchanged. Hour 3 does something close to this when it keeps the items in order and switches to a much faster search, and the idea returns in full force in Hour 9, on abstract data types.

setElem / getEleminsert / find / removemain()search, shift, countLowArrayholds vector vmain()just asksHighArrayv, nElems, find,insert, remove
Who does the work. With LowArray (top), main() keeps the count and runs every loop, reaching the data one cell at a time. With HighArray (bottom), the class owns the count and the algorithms, and main() simply asks.

Summary

  • An unsorted array inserts a new item in one step, with no comparisons, by putting it in the first empty cell (index nElems).
  • Linear search takes about N/2 comparisons on average when the item is present, and N when it isn't.
  • Deletion takes about N/2 comparisons to find the item plus about N/2 moves to close the gap, because the array must not have holes.
  • If duplicates are allowed, searches and deletions must examine all N items. Insertion stays one step unless you must check for a duplicate first.
  • A C++ array has a fixed size set at compile time, indexes from 0 to size − 1, and no bounds checking.
  • Moving the array into a class (LowArray) hides the data, but a low-level interface leaves the client doing all the work.
  • HighArray moves the count and the algorithms into the class, so its client just calls insert(), find(), remove() and display().
  • Abstraction, separating what a class does from how it does it, lets you change a class's insides without touching its clients.

Q&A

Why not just mark a deleted cell as empty instead of shifting everything down?
You could, and some structures do. The hash tables of Hour 21 mark deleted cells with a special value. In an array, though, holes complicate everything else. Insertion would have to hunt for an empty cell, and every search would have to skip holes and could no longer stop at nElems. Shifting keeps the other operations simple, at the cost of slower deletion.
Why does HighArray use a vector rather than a plain array?
So the client can choose the size while the program runs, by passing it to the constructor. A plain array's size must be fixed when the program is compiled. The vector also knows its own size and could be grown if it filled up.
Is linear search ever the right choice?
Yes. For a small array, a few dozen items or so, it's fast enough and as simple as code gets. It's also the only choice when the data is in no particular order and you search too rarely to justify sorting it.
remove() deletes only the first match. How would I delete every copy of a key?
The simplest way is to call remove() in a loop until it returns false. Each call searches from the start again, so that's slow when there are many copies. A faster approach makes a single pass, copying each item that should stay down into the next free position.

Quiz

  1. In an unsorted array of N items with no duplicates, about how many comparisons does it take to find an item that is present? One that is absent?

    Answer

    About N/2 on average for an item that is present, and N for an item that is absent, because the search must check every item before it can give up.

  2. About how many comparisons and moves does it take to delete an item from an unsorted array of 1,000 items?

    Answer

    On average about 500 comparisons to find the item and about 500 moves to shift the items above it down one cell.

  3. Why do the array algorithms in this hour insist that there be no holes?

    Answer

    Because the other algorithms rely on the items filling cells 0 to nElems − 1. Insertion uses nElems as the index of the first empty cell, and searching stops at the last item. With holes, both would have to check for empty cells.

  4. What changes when duplicate keys are allowed?

    Answer

    Searching for every match takes N comparisons instead of N/2, and deleting every copy takes N comparisons and more than N/2 moves. Insertion is still one move with no comparisons.

  5. Why is HighArray a better class than LowArray?

    Answer

    HighArray keeps the item count and performs searching, insertion and deletion itself, so its clients only ask for what they want. The client code is shorter and clearer, can't corrupt the array, and keeps working if the class's inner workings change.

  6. In remove(), why does the shifting loop stop when k reaches nElems − 1?

    Answer

    The last copy it needs moves v[nElems-1], the final item, into v[nElems-2]. Going one step further would read v[nElems], which is past the last item, and past the end of the vector when the array is full.

3

Part I · Introducing Data Structures and Algorithms

Hour 3 Ordered Arrays

Keep an array's items in order and you can find any of them with binary search, which is far faster than checking cell by cell. This hour shows how binary search works, measures it with logarithms, stores whole objects in an array, introduces Big O notation, and explains why arrays still aren't enough.

In this hour you'll learn

  • How an ordered array keeps its items in key order, and what that costs on insertion
  • How binary search finds an item by halving the range, just like a guessing game
  • How logarithms tell you how many steps binary search needs
  • How to store objects with several fields in an array
  • How Big O notation describes the way running time grows with N
  • Why arrays can't be the answer to every storage problem

The Ordered Workshop Applet

An ordered array keeps its items sorted by key, with the smallest key in cell 0 and each cell holding a key at least as large as the one before it. The book's Ordered Workshop applet shows such an array. It looks and behaves much like the Array applet from Hour 2. The main difference is that pressing New to create an array also asks you to choose a search method, linear or binary.

Insertion

Order comes at a price, and insertion pays it. A new item must take its proper place among the others, so insertion finds the first item with a larger key, moves it and every item above it up one cell, and copies the new item into the gap. On average about N/2 items move, where an unsorted array needed a single move. Deletion works as in Hour 2, shifting the larger items down to close the hole.

If you choose linear search, the applet steps through the cells from the left, just as before, with one small advantage. Because the keys increase from left to right, the search can stop as soon as it reaches a key larger than the one it wants. A failed search therefore gives up about halfway on average, after roughly N/2 comparisons instead of N. A successful search still takes about N/2.

The real payoff of keeping things in order is binary search, which finds an item by repeatedly cutting the range of possible cells in half. The idea is easiest to see in a children's game.

The Guess-a-Number Game

A friend thinks of a number between 1 and 100 and asks you to guess it. After each guess, the friend tells you whether you're too high, too low, or right. You could start at 1 and work upward, but that might take 100 guesses.

The smart move is to guess the middle, 50. Whatever the answer, one guess throws away half the possibilities. Then you guess the middle of the half that's left, and keep going. Here is how the game goes when the secret number is 33.

Guessing the number 33 by always choosing the middle
StepGuessResultRange still possible
050Too high1–49
125Too low26–49
237Too high26–36
331Too low32–36
434Too high32–33
532Too low33–33
633CorrectFound it

It took seven guesses, numbered 0 through 6. Each guess is the middle of the remaining range, rounded down when the middle falls between two numbers. Starting from 100 possibilities, at most 50 remain after the first guess, then at most 25, 12, 6, 3 and finally 1, so this strategy never needs more than seven guesses for a number from 1 to 100.

100502512631
The most numbers that can still be in play before each of the seven guesses, from left to right: 100, 50, 25, 12, 6, 3 and 1. Halving shrinks the range so quickly that the seventh guess always faces a single candidate.

Our Workshop panel turns the game around. You think of a number, and the computer finds it by binary search.

Binary Search in the Ordered Applet

Binary search in an ordered array plays the same game with cell numbers. The applet shows a lower bound and an upper bound enclosing the cells where the key could still be, starting at the first and last cells. At each step it points at the cell halfway between them and compares that cell's key with the one you want.

If the middle key is too small, the lower bound jumps to just past the middle; if it's too large, the upper bound jumps to just before it. Half the remaining cells drop out each time, so while linear search plods from cell to cell, binary search finishes in a handful of steps.

C++ Code for an Ordered Array

The book's program orderedArray.cpp defines a class called OrdArray. It resembles HighArray from Hour 2, with a private vector<double> v, a count nElems, and a small function getSize() that returns the count. The big changes are in find(), which now uses binary search, and in insert(), which keeps the items in order.

Binary Search with the find() Member Function

orderedArray.cpp The OrdArray class, part 1: binary search
// orderedArray.cpp
// An array class that keeps its items in ascending order
#include <iostream>
#include <vector>
using namespace std;

class OrdArray {
private:
    vector<double> v;
    int nElems;

public:
    OrdArray(int max) : nElems(0) {
        v.resize(max);
    }

    int getSize() {
        return nElems;
    }

    // binary search: returns the index of searchKey,
    // or nElems if searchKey isn't in the array
    int find(double searchKey) {
        int lowerBound = 0;
        int upperBound = nElems - 1;

        // keep going while the range still holds at least one cell
        while (lowerBound <= upperBound) {
            // look at the cell in the middle of the range
            int curIn = (lowerBound + upperBound) / 2;
            if (v[curIn] == searchKey)
                return curIn;

            // throw away the half that can't contain the key
            if (v[curIn] < searchKey)
                lowerBound = curIn + 1;
            else
                upperBound = curIn - 1;
        }
        // the range is empty, so the key is not here
        return nElems;
    }

lowerBound and upperBound mark the ends of the range still in play. Each pass through the loop computes curIn, the index halfway between them; integer division drops any fraction, so the midpoint of 8 and 15 is 11.

If v[curIn] equals the search key, find() returns the index. If v[curIn] is smaller, the key can only lie to the right, so lowerBound moves to curIn + 1. Otherwise it can only lie to the left, and upperBound moves to curIn - 1. Either way, the middle cell and everything on the wrong side of it are ruled out.

If the key isn't there, the bounds eventually cross, with lowerBound greater than upperBound. The range is empty, the loop ends, and find() returns nElems, a value that can never be the index of a real item. The caller compares the result with getSize() to tell success from failure.

Here is a search for 57 in an array of 16 items. The first step examines cell 7.

3091142213274335386457528579631070117612841391149815curInlowerBoundupperBound
Step 1. The range is the whole array, cells 0 to 15, so curIn is (0 + 15) / 2 = 7.

Cell 7 holds 45, which is smaller than 57, so the lower half is discarded and lowerBound becomes 8.

3091142213274335386457528579631070117612841391149815curInlowerBoundupperBound
Step 2. Cells 0 to 7 are out (faded). The new midpoint is (8 + 15) / 2 = 11.

Cell 11 holds 70, which is too large, so upperBound becomes 10. The third step examines cell 9 and finds the key.

3091142213274335386457528579631070117612841391149815curInlowerBoundupperBound
Step 3. Cells 11 to 15 are out as well. The midpoint of 8 and 10 is 9, and cell 9 holds 57.

Binary search found the key in three steps, each examining one cell. A linear search would have examined ten cells, 0 through 9.

The OrdArray Class

The rest of the class handles insertion, deletion and display.

orderedArray.cpp The OrdArray class, part 2: keeping the order
    // put value in its proper place, keeping the order
    void insert(double value) {
        int j;
        // find the first item larger than value
        for (j = 0; j < nElems; j++)
            if (v[j] > value)
                break;

        // move items j and above up one cell, highest first
        for (int k = nElems; k > j; k--)
            v[k] = v[k - 1];

        v[j] = value;
        nElems++;
    }

    bool remove(double value) {
        int j = find(value);
        if (j == nElems)
            return false;

        // close the gap by moving larger items down one cell
        for (int k = j; k < nElems - 1; k++)
            v[k] = v[k + 1];
        nElems--;
        return true;
    }

    void display() {
        for (int j = 0; j < nElems; j++)
            cout << v[j] << " ";
        cout << endl;
    }
};

insert() works in two stages. The first loop scans from the left for the first item larger than the new value, and when it stops, j is the cell where the value belongs (nElems if every item is smaller). The second loop opens a gap at j. It must start at the top and work down, so that each copy lands in a cell that is already free; starting at j would overwrite each item before it had been moved.

001112224435546657768879989j
Inserting 33. The scan stops at j = 3, where 44 is the first larger item. Working down from the top, 99, 88, 77, 66, 55 and 44 each move up one cell (six moves), and then 33 goes into cell 3.

remove() calls find(), so deletion also benefits from binary search when it locates the item. The shifting afterward is the same as in HighArray.

The orderedArray.cpp Program

The main() function inserts the same ten numbers as in Hour 2, in the same scrambled order. It then searches for 55, displays the array, removes three items, and displays it again.

orderedArray.cpp main()
int main() {
    OrdArray arr(100);

    // insert ten numbers in scrambled order
    double keys[] = {77, 99, 44, 55, 22, 88, 11, 0, 66, 33};
    for (int j = 0; j < 10; j++)
        arr.insert(keys[j]);

    double searchKey = 55;
    if (arr.find(searchKey) != arr.getSize())
        cout << "Found " << searchKey << endl;
    else
        cout << "Can't find " << searchKey << endl;

    arr.display();

    arr.remove(0);
    arr.remove(55);
    arr.remove(99);
    arr.display();
    return 0;
}
Found 55
0 11 22 33 44 55 66 77 88 99
11 22 33 44 66 77 88

Although the numbers went in scrambled, they come out sorted, because insert() put each one in its place. The last insertion, of 33, is the one pictured above.

Advantages of Ordered Arrays

The trade is easy to state. Searching becomes far faster, insertion becomes slow because half the items must move on average, and deletion is slow in both kinds of array.

So an ordered array pays off when searches are frequent and insertions and deletions are rare. The book's example is a database of company employees, where people are looked up constantly but hiring and firing happen far less often. A retail store's inventory is the opposite case. Items arrive and sell all day, and an ordered array would spend much of its time shifting data around.

Logarithms

Binary search is fast, and logarithms tell you exactly how fast. The word sounds intimidating, but here it has a very concrete meaning. Start with the numbers.

Comparisons needed to search N items
Range (N)Binary search, at mostLinear search, average (N/2)
1045
100750
1,00010500
10,000145,000
100,0001750,000
1,000,00020500,000
10,000,000245,000,000
100,000,0002750,000,000
1,000,000,00030500,000,000

For small arrays the difference hardly matters, since 4 comparisons against 5 is nothing. For a million items, though, binary search needs at most 20 comparisons where linear search averages 500,000, and for a billion items it needs just 30. Making the array ten times bigger adds only three or four steps to a binary search.

The Equation

To see where these numbers come from, think about the search backward. Each step cuts the range in half, so each extra step doubles the range you can handle. If some range takes s steps, a range twice as big takes s + 1, because its first step cuts it back down. Start from a range of 1, which needs no steps, and double it s times. The range r that s steps can cover is

r = 2s

Powers of two: the range r that s steps can cover
Steps s012345678910
Range r = 2s12481632641282565121,024

Seven steps cover 128 values, more than 100, which is why seven guesses are enough for the number game. Ten steps cover 1,024, so a thousand items need 10 comparisons.

The Opposite of Raising Two to a Power

The equation r = 2s tells you how big a range s steps can handle. A search needs the reverse, the number of steps s for a given range r. The operation that undoes raising 2 to a power is the base-2 logarithm, written log₂, so

s = log₂(r)

In words, log₂(r) is the power you must raise 2 to in order to get r. Since 210 = 1,024, log₂(1,024) = 10. When r isn't an exact power of 2, the logarithm has a fractional part, and you round up to get the number of steps. For example, log₂(100) is about 6.64, which rounds up to 7.

Many calculators only have a key for base-10 logarithms. To convert, multiply by 3.322, since log₂(x) = log₁₀(x) × 3.322. For a million items, log₁₀(1,000,000) is 6, and 6 × 3.322 = 19.93, which rounds up to 20, just as the table says.

Storing Objects

So far the arrays have held plain numbers. Real databases hold records with several fields, like the address cards in Hour 1. The book's next program, classDataArray.cpp, shows how an array can hold objects, using a simple record that describes a person.

The Person Class

A Person object has three fields: a last name, a first name, and an age. The constructor fills them in, displayPerson() prints them, and getLast() returns the last name so that other code can use it as the key.

classDataArray.cpp The Person class
// classDataArray.cpp
// An array of pointers to Person objects, searched by last name
#include <iostream>
#include <string>
#include <vector>
using namespace std;

class Person {
private:
    string lastName;
    string firstName;
    int age;

public:
    Person(string last, string first, int a)
        : lastName(last), firstName(first), age(a) {
    }

    void displayPerson() {
        cout << lastName << ", " << firstName
             << ", age " << age << endl;
    }

    // the key field, used for searching
    string getLast() {
        return lastName;
    }
};

The ClassDataArray Class

The array class, ClassDataArray, is HighArray adapted to hold people. Its vector doesn't contain Person objects themselves. It holds pointers to them, vector<Person*>, and each object is created with new when it is inserted.

classDataArray.cpp The ClassDataArray class
class ClassDataArray {
private:
    // each cell points to a Person stored elsewhere in memory
    vector<Person*> v;
    int nElems;

public:
    ClassDataArray(int max) : nElems(0) {
        v.resize(max);
    }

    // free the people still in the array
    ~ClassDataArray() {
        for (int j = 0; j < nElems; j++)
            delete v[j];
    }

    // linear search on the key field; NULL means not found
    Person* find(string searchName) {
        for (int j = 0; j < nElems; j++)
            if (v[j]->getLast() == searchName)
                return v[j];
        return NULL;
    }

    // build a new Person and keep a pointer to it
    void insert(string last, string first, int age) {
        v[nElems] = new Person(last, first, age);
        nElems++;
    }

    bool remove(string searchName) {
        int j;
        for (j = 0; j < nElems; j++)
            if (v[j]->getLast() == searchName)
                break;
        if (j == nElems)
            return false;

        // free the object, then slide the later pointers down
        delete v[j];
        for (int k = j; k < nElems - 1; k++)
            v[k] = v[k + 1];
        nElems--;
        return true;
    }

    void displayA() {
        for (int j = 0; j < nElems; j++)
            v[j]->displayPerson();
    }
};

The last name is the key field. find() takes a search key such as "Stimson" and compares it with v[j]->getLast() for each person, using the string class's == operator. When it finds a match it returns the pointer, which gives the caller the whole record. When it fails, it returns NULL, a pointer to nothing.

insert() receives the three field values, builds a new Person with new, and stores its address in the next free cell. remove() searches by last name, frees the object with delete, and shifts the remaining pointers down. Our version also adds a destructor, ~ClassDataArray(), which runs automatically when the array object is destroyed and frees the people still stored.

v[0]v[1]v[2]v[3]Evans · Patty · 24Smith · Lorraine · 37Yee · Tom · 43Adams · Henry · 63
The vector holds only pointers (the first four cells are shown), and each points to a Person object that new created elsewhere in memory. Removing Evans copies the pointers in v[1], v[2], v[3] and beyond down one cell, while the Person objects stay where they are.

Storing pointers pays off whenever items move. When remove() closes a gap, each step copies one pointer, just a few bytes, however big the Person objects are.

The classDataArray.cpp Program

The main() function inserts ten people, searches for one, removes three, and displays the rest.

classDataArray.cpp main()
int main() {
    ClassDataArray arr(100);

    arr.insert("Evans", "Patty", 24);
    arr.insert("Smith", "Lorraine", 37);
    arr.insert("Yee", "Tom", 43);
    arr.insert("Adams", "Henry", 63);
    arr.insert("Hashimoto", "Sato", 21);
    arr.insert("Stimson", "Henry", 29);
    arr.insert("Velasquez", "Jose", 72);
    arr.insert("Lamarque", "Henry", 54);
    arr.insert("Vang", "Minh", 22);
    arr.insert("Creswell", "Lucinda", 18);

    string searchKey = "Stimson";
    Person* found = arr.find(searchKey);
    if (found != NULL) {
        cout << "Found ";
        found->displayPerson();
    }
    else
        cout << "Can't find " << searchKey << endl;

    arr.remove("Evans");
    arr.remove("Smith");
    arr.remove("Yee");
    arr.displayA();
    return 0;
}
Found Stimson, Henry, age 29
Adams, Henry, age 63
Hashimoto, Sato, age 21
Stimson, Henry, age 29
Velasquez, Jose, age 72
Lamarque, Henry, age 54
Vang, Minh, age 22
Creswell, Lucinda, age 18

The search for Stimson succeeds and prints his whole record, and after Evans, Smith and Yee are removed, the other seven people remain in the order they were inserted. This array is unordered, so find() searches linearly. You could keep the pointers sorted by last name and use binary search instead, as OrdArray does with numbers.

Big O Notation

You need a compact way to describe an algorithm's efficiency that doesn't depend on a particular computer. A statement such as "binary search is 50 times faster than linear search" doesn't work, because the ratio changes with the size of the array. For 10 items the two are nearly even, and for a million items binary search wins by a factor of 25,000. What matters is how the running time grows as the number of items, N, grows, and Big O notation is the standard way to describe that growth.

Insertion in an Unordered Array: Constant

Inserting into an unsorted array takes the same time however many items the array holds, because the new item always goes into the next empty cell. The time T is a constant, K.

T = K

K depends on things like the speed of the processor and how well the compiler translates the code. It doesn't depend on N.

Linear Search: Proportional to N

A linear search examines about N/2 items on average, so its time is

T = K × N/2

The 1/2 is just another constant, so it can be folded into K. Calling the combined constant K again gives T = K × N. Double the array and the search takes twice as long.

Binary Search: Proportional to log(N)

Binary search takes about log₂N steps, so T = K × log₂N. Here there's a further simplification. Logarithms in different bases differ only by a constant factor (you saw that log₂N = 3.322 × log₁₀N), so the base can be folded into K as well and left out, giving T = K × log N.

Don't Need the Constant

Big O notation goes one step further and drops K altogether, because K says nothing about how the time grows. What remains is written with a capital O, read as "order of." Insertion into an unsorted array is O(1), linear search is O(N), and binary search is O(log N). The simple sorts in Hours 4 and 5 will turn out to be O(N²), which means that doubling the number of items makes them take four times as long.

Running times in Big O notation
AlgorithmRunning time
Linear searchO(N)
Binary searchO(log N)
Insertion in an unordered arrayO(1)
Insertion in an ordered arrayO(N)
Deletion in an unordered arrayO(N)
Deletion in an ordered arrayO(N)
01020304005101520N (number of items)StepsO(1)O(log N)O(N)O(N log N)O(N²)O(1)O(log N)O(N)O(N log N)O(N²)
How the common running times grow as N increases. O(1) stays flat (excellent), O(log N) rises very slowly (good), O(N) climbs steadily (fair), and O(N²) shoots upward (poor). The chart also shows O(N log N), which you'll meet with the faster sorts of Part III.

Big O can't predict how many microseconds a particular run will take, but it shows how the time changes as N grows, which is what you need when choosing between algorithms for large amounts of data. For small N the constants can matter more, and a simple O(N) method might even beat an O(log N) one. As N grows, the algorithm with the slower growth rate always pulls ahead.

To feel the gap, suppose each step takes one microsecond and N is a million. An O(1) operation takes a microsecond, and an O(log N) one takes about 20 microseconds. An O(N) operation takes a full second. An O(N²) operation takes 1012 microseconds, which is more than 11 days.

Why Not Use Arrays for Everything?

Arrays are simple, and an ordered array with binary search finds things quickly. They still fall short in two ways, which is why the book has 21 more hours to go.

The first is speed. An unordered array inserts in O(1) time but searches in O(N). An ordered array searches in O(log N) time but inserts in O(N). Either way, deletion takes O(N), because items must shift to fill the gap. For data that is searched, inserted and deleted often, neither kind of array is good enough.

The second weakness is the fixed size. You must choose an array's size when you create it, usually without knowing how much data will arrive. Guess too small and the array overflows; guess too big and the unused cells waste memory. A vector can grow, but growing means allocating a bigger block of memory and copying every item into it, which is slow.

What's needed is a structure that is fast at insertion, deletion and searching, and that grows and shrinks with the data. The book builds toward that goal step by step, with linked lists in Hour 8, binary trees in Hour 15, and hash tables in Hour 21. First, though, Hour 4 takes up a question that ordered arrays raise, which is how to put data into order in the first place.

Summary

  • An ordered array keeps its items sorted by key, which lets binary search find any item in O(log N) steps.
  • Binary search checks the middle of the remaining range and discards the half that can't hold the key, like guessing a number from 1 to 100 in at most seven tries.
  • Insertion into an ordered array must shift larger items up to make room, so it takes O(N) time. Deletion is O(N) in both kinds of array.
  • Ordered arrays suit data that is searched often and changed rarely, such as an employee database, and not fast-changing data such as a store's inventory.
  • s steps can search a range of r = 2s items, so s = log₂(r), rounded up. To get log₂ from log₁₀, multiply by 3.322.
  • Binary search needs only 20 comparisons for a million items and 30 for a billion.
  • Arrays can hold objects with several fields. Storing pointers to the objects makes shifting cheap, and one field, such as the last name, serves as the key.
  • Big O notation describes how running time grows with N, ignoring constants. From best to worst, the common cases are O(1), O(log N), O(N) and O(N²).
  • Arrays have slow operations and a fixed size, which motivates the linked lists, trees and hash tables to come.

Q&A

If binary search is so fast, why does insert() find its spot with a linear scan?
Because the shifting that follows takes about N/2 moves anyway. A binary search would cut the comparisons to about log₂N, but insertion would still be O(N) overall, so the simple loop keeps the code short without changing the Big O.
Does it matter which base I use for logarithms?
Not in Big O notation. Logarithms in different bases differ only by a constant factor, such as the 3.322 between base 10 and base 2, and Big O ignores constants. When you want an actual count of binary search steps, use base 2.
Big O throws away the constants. Couldn't an O(N) algorithm beat an O(log N) one?
For small N, yes, if its constant is much smaller. Growth rates always win in the end, though. Beyond some size the O(log N) algorithm is faster, and its lead keeps growing with N.
Why does ClassDataArray store pointers instead of Person objects?
Shifting pointers is cheap, whatever the size of the objects they point to. Pointers also let find() hand back the actual record rather than a copy, with NULL as a natural signal for "not found."

Quiz

  1. Using binary search, what is the largest number of guesses needed to find a number between 1 and 1,000?

    Answer

    10, because 210 = 1,024 is the first power of 2 that reaches 1,000.

  2. What is log₂ of 1,024?

    Answer

    10, since 2 raised to the 10th power is 1,024.

  3. What is the Big O running time of binary search? Of insertion into an ordered array?

    Answer

    Binary search is O(log N). Insertion into an ordered array is O(N), because about half the items must shift.

  4. Why does Big O notation drop constants?

    Answer

    Constants depend on the computer, the compiler and the details of the code, and they don't change how the time grows as N grows. Big O compares growth rates, which decide the winner once N is large.

  5. When is an ordered array a good choice, and when is it a poor one?

    Answer

    It's good when searches are frequent and insertions and deletions are rare, as in an employee database. It's poor when the data changes constantly, as in a store's inventory, because every insertion or deletion shifts about half the items.

  6. In insert(), why must the shifting loop start at the top and work down?

    Answer

    Moving the highest item first means each copy goes into a cell that is already free. Working upward from j would overwrite each item before it had been copied, destroying data.

4

Part I · Introducing Data Structures and Algorithms

Hour 4 The Bubble Sort

Putting data in order is one of the jobs computers do most often. This hour builds the simplest sorting method there is, the bubble sort, and uses it to show that every sort is made of tiny steps whose cost you can count.

In this hour you'll learn

  • Why sorting matters and how it ties in with binary search
  • How a program that sees only two items at a time can still sort
  • How the bubble sort works, first with baseball players and then in the Workshop applet
  • How to write the bubble sort in C++, line by line
  • What an invariant is and how it helps you trust an algorithm
  • Why the bubble sort runs in O(N²) time

Sorting

As soon as a database holds more than a handful of records, someone wants to see them in order. You might list names alphabetically, students by grade, customers by zip code, home sales by price, cities by population, countries by gross national product, or stars by how bright they appear. Underneath, each of these is the same job. You arrange the items by the value of one field, the key.

Order also pays off when you search. In Hour 3 you saw that binary search needs only about 20 comparisons to find an item among a million, while a linear search needs about 500,000 on average. Binary search works only on data that is already in order, so sorting is often the preliminary step that makes fast searching possible.

Because sorting is so useful, and can take so long, it has been studied as closely as any problem in computing. Lafore splits the subject into two groups. This hour and the next cover two simple sorts, the bubble sort and the insertion sort. Part III comes back with the fast ones, mergesort in Hour 12 and quicksort in Hours 13 and 14.

The simple sorts run in O(N²) time, which makes them slow for large amounts of data. They still earn their place. They are easy to understand and easy to code, they are quick enough when N is small, and their ideas return inside the advanced sorts. The insertion sort, for example, is often used to finish off the work of quicksort.

Inventing Your Own Sorting Algorithm

Before looking at any code, picture a kids' baseball team. Nine players have wandered onto the field and stand in a line in no particular order. You want them lined up by height, with the shortest player on the left. Think for a moment about how you would do it.

739518462
Nine players standing in the order they arrived. The goal is a line whose heights rise steadily from left to right.

You would probably solve it without much thought. You can see the whole line at once, so you spot the shortest child and wave her to the left end, then spot the next shortest, and so on. If a couple of players are only slightly out of place, you notice that too and fix it with a quick shuffle.

A computer program has no such overview. At any moment it can look at just two items and compare them. Lafore likens this to being very nearsighted. You can make out the two players standing right in front of you, and everyone else is a blur. Whatever method you invent has to be built out of these close-up looks.

That limitation reduces every sorting algorithm to two basic operations, repeated many times.

  1. Compare two items to see which one is larger.
  2. Swap two items, or in some algorithms copy one item to a new place.

The sorts in this book differ in which pairs they compare and in when and how they move items. That is why each sort gets measured by counting its comparisons and its swaps or copies. Those two counts decide how fast it runs. In a program, each player becomes a number in an array cell, and "taller" becomes "holds the larger value."

Bubble-Sorting the Baseball Players

The bubble sort is famous for being slow, and just as famous for being simple. Here is how it works on the team. Stand at the left end of the line. Compare the players in positions 0 and 1. If the one on the left is taller, have them trade places. If not, leave them alone. Then move one position to the right and compare the players in positions 1 and 2, and keep going this way until you reach the right end. Each step follows three rules.

  1. Compare two players.
  2. If the one on the left is taller, swap them.
  3. Move one position to the right.

Follow the tallest player during this first trip down the line. Until you reach him, nothing happens to him. Once you do, he is compared with each player to his right in turn, and he is always the taller of the two, so he is swapped at every step. He gets carried all the way to the right end, the way a bubble rises through a glass of soda. That is where the name comes from. On each trip down the line, the largest remaining item bubbles up to the end.

739518462outinin+1
The first comparison. The player in position 0 (height 7) is taller than the one in position 1 (height 3), so they swap.
375918462outinin+1
The fourth comparison. The tallest player (height 9) has reached position 3. From here on he is taller than every neighbor, so he is swapped at each step.
375184629out
The end of the first pass, after 8 comparisons and 7 swaps. The tallest player has bubbled to the right end and will never move again.

A first pass through a line of N players makes N−1 comparisons (8 for our nine players) and some number of swaps that depends on how the players started out. In our line, 7 of the 8 comparisons led to a swap. After the pass, only one player is sure to be in his final place, the tallest.

Now go back to the left end and start a second pass. This time you can stop one position earlier, because the rightmost spot is settled. The second pass carries the second-tallest player to the second spot from the right. The third pass settles the third-tallest, and so on. Each pass puts one more player in his final place and makes the next pass one step shorter. So the rules get one more line: when you reach the first player who is already sorted, go back to the left end and start over. When there are no unsorted players left, you are done.

Here is the whole sort of our team, pass by pass.

Bubble-sorting the nine players
PassComparisonsSwapsHeights after the pass
1873 7 5 1 8 4 6 2 9
2753 5 1 7 4 6 2 8 9
3643 1 5 4 6 2 7 8 9
4531 3 4 5 2 6 7 8 9
5411 3 4 2 5 6 7 8 9
6311 3 2 4 5 6 7 8 9
7211 2 3 4 5 6 7 8 9
8101 2 3 4 5 6 7 8 9
Total3622

The table shows two patterns that matter later. First, the number of comparisons in each pass is fixed: 8, then 7, then 6, down to 1, however the players happened to stand. Second, tall players travel right quickly, but short players creep left slowly. Watch the player of height 2, who starts at the right end. A taller player passing him can push him one place to the left, but that happens at most once per pass, so he needs seven passes to reach position 1.

Notice also that the line was in order after pass 7, yet the rules still call for pass 8. The plain bubble sort has no way to notice that it has finished early. It simply makes all of its passes.

The bubbleSort Workshop Applet

The Workshop applet for this hour draws the data as a row of vertical bars with random heights, one bar per item. Five buttons control it.

  • Run animates the whole sort from start to finish, so you can watch the bars shuffle into order.
  • Step carries out a single comparison, plus the swap if one is needed, each time you press it.
  • Size switches between 10 bars and 100 bars.
  • New makes a fresh set of random bars. Pressed again, it produces an inversely sorted set, tallest bar on the left, which is the hardest case for the bubble sort.
  • Draw repaints the bars, in case some of them fail to appear properly during a fast run.

While the sort runs, two arrows labeled inner and inner+1 point at the pair of bars being compared. A third arrow, labeled outer, marks the right edge of the unsorted part, and the bars beyond it are finished. Counters show how many comparisons and swaps have been made so far.

With 10 bars you can step through the sort, predict each decision before the applet makes it, and see the tallest remaining bar ride to the right on every pass. With 100 bars the single steps blur together and you see the big picture instead. The tallest bars stack up at the right end in a staircase that grows leftward, while the left side stays a jumble almost until the end. You also get a feel for how slow the bubble sort is once there is a real amount of data.

Implementing C++ Code for a Bubble Sort

The program bubbleSort.cpp keeps its data in a class named ArrayBub, built along the same lines as the array classes of Hour 2. The private data is a vector<double> called v and an int called nElems that counts how many cells are in use. The public interface offers a constructor, insert(), display() and the new member, bubbleSort(). A private helper, swap(), trades two items.

bubbleSort.cpp The ArrayBub class
// bubbleSort.cpp
// sorts a list of numbers with the bubble sort
#include <iostream>
#include <vector>
using namespace std;

class ArrayBub
   {
   private:
      // storage for the items
      vector<double> v;
      // how many cells are in use
      int nElems;

      // trade the items held in two cells
      void swap(int one, int two)
         {
         double temp = v[one];
         v[one] = v[two];
         v[two] = temp;
         }

   public:
      // reserve max cells; the array starts out empty
      ArrayBub(int max) : nElems(0)
         { v.resize(max); }

      // add an item just past the last one
      void insert(double value)
         {
         v[nElems] = value;
         nElems++;
         }

      // show every item on one line
      void display()
         {
         for(int j=0; j<nElems; j++)
            cout << v[j] << " ";
         cout << endl;
         }

      void bubbleSort();
   };

Most of the class is familiar. The constructor sizes the vector to hold max items and sets nElems to zero. insert() places a new value in the first free cell, the one at index nElems, and then bumps the count. display() prints the items in order.

swap() needs the temporary variable temp. If you wrote v[one] = v[two] straight away, the old value of v[one] would be overwritten before you had a chance to move it. So the function parks one value in temp, copies the other value across, and then copies the parked value into the second cell. It is private because it is a tool for the class's own use. Code outside the class has no reason to call it.

bubbleSort.cpp The bubbleSort() member function
void ArrayBub::bubbleSort()
   {
   int out, in;
   // out is the last cell of the unsorted part;
   // it moves one cell to the left after every pass
   for(out=nElems-1; out>0; out--)
      {
      // in walks the unsorted part from the left end
      for(in=0; in<out; in++)
         {
         // a bigger item on the left means the pair
         // is out of order, so trade places
         if( v[in] > v[in+1] )
            swap(in, in+1);
         }
      }
   }

The whole algorithm is two nested for loops. The outer loop's variable, out, starts at the last occupied cell, nElems-1, and moves one cell to the left after each pass. Think of it as the right edge of the unsorted part. Every cell with a higher index than out already holds its final item.

The inner loop's variable, in, starts at 0 on every pass and climbs toward out. At each position it compares the neighbors v[in] and v[in+1], and if the left one is larger it calls swap() to exchange them. The loop stops when in reaches out, so the last comparison of each pass is between v[out-1] and v[out]. That final comparison delivers the largest unsorted item into cell out, just before out moves left.

Lafore makes swap() a separate function so that bubbleSort() stays short and readable. A function call costs a little time, though, and this one sits in the innermost loop, where it may run millions of times. A program tuned for speed would put the three lines of the swap directly inside the if statement.

The main() function tests the class. It creates an ArrayBub with room for 100 items, inserts ten numbers in scrambled order, displays them, sorts them and displays them again.

bubbleSort.cpp Testing the sort in main()
int main()
   {
   // room for 100 numbers, though only 10 are used
   ArrayBub arr(100);

   // ten test values in scrambled order
   double data[] = { 77, 99, 44, 55, 22, 88, 11, 0, 66, 33 };
   for(int j=0; j<10; j++)
      arr.insert( data[j] );

   // show the list, sort it, show it again
   arr.display();
   arr.bubbleSort();
   arr.display();
   return 0;
   }

The first line of output is the data in the order it went in. The second is the same data after bubbleSort() has put it in ascending order.

77 99 44 55 22 88 11 0 66 33
0 11 22 33 44 55 66 77 88 99

Invariants

Many algorithms have a condition that stays true for as long as they run. Such a condition is called an invariant. Spotting it helps you see why an algorithm works, and it gives you something concrete to check when a program misbehaves.

In bubbleSort() the invariant is that the items to the right of out are sorted and sit in their final positions. At the start this holds in a trivial way, because out is at the right end and nothing lies beyond it. Each pass of the inner loop carries the largest unsorted item into cell out, and then out steps one cell left. So the sorted zone on the right grows by exactly one item per pass. Nothing inside that zone is ever touched again, since the inner loop never looks past out.

315462789out
The nine heights after three passes. The outer loop has moved out to position 5, and everything to its right (7, 8 and 9) is sorted and final. That is the invariant.

The invariant also explains why the array must be sorted when the outer loop ends. The last pass runs with out equal to 1, and afterward out drops to 0. By the invariant, every cell from 1 to the end now holds its final item. The single item left over, in cell 0, can only be the smallest, so the whole array is in order.

As a debugging aid, an invariant gives you a precise test. Suppose a sort you wrote leaves the data slightly scrambled. You could add a temporary call to display() at the end of each outer pass and check that the right-hand zone is sorted and unchanged from one pass to the next. The first pass where the check fails is where the bug does its damage.

Efficiency of the Bubble Sort

Counting comparisons is easy, because their number doesn't depend on the data. Take the ten numbers in bubbleSort.cpp. The first pass makes 9 comparisons, the second makes 8, and so on down to a single comparison on the last pass. The total is 9+8+7+6+5+4+3+2+1 = 45.

For N items the sum is (N−1)+(N−2)+(N−3)+…+1, which works out to N×(N−1)/2. For N = 10 that gives 10×9/2 = 45, as expected. When N is large, subtracting 1 hardly changes anything, so the bubble sort makes roughly N²/2 comparisons.

987654321
Comparisons per pass when sorting 10 items. Drawn as squares, the nine passes form a staircase of 45 squares, exactly half of a 10-by-9 rectangle. That is where N×(N−1)/2 comes from.

Swaps are fewer, since a swap happens only when a pair is out of order, and their number depends on the data. If the data is random, a swap is needed about half the time, so there are about N²/4 swaps. In the worst case, when the data starts out inversely sorted, every comparison finds a pair out of order and triggers a swap, which gives about N²/2 swaps. Data that is already sorted needs no swaps at all, but the comparisons still run their full course.

How the bubble sort's work grows with N
NComparisons, N×(N−1)/2Swaps on random data, about N²/4
104525
1004,9502,500
1,000499,500250,000
10,00049,995,00025,000,000

Both columns grow in proportion to N². Multiply N by 10 and the work goes up about 100 times. Big O notation ignores constant factors such as the 2 and the 4, so the bubble sort runs in O(N²) time. With 10,000 items it makes about 50 million comparisons. At one microsecond per comparison, that is 50 seconds spent just comparing, before a single swap is counted. You can feel this slowness in the Workshop applet with 100 bars.

There's a quick way to recognize this kind of cost in any code. When one loop is nested inside another, and each loop runs about N times (or N divided by some constant), the body of the inner loop executes roughly N×N times. In the bubble sort the outer loop makes N−1 passes and the inner loop averages about N/2 steps per pass. Nested loops of this kind are a strong hint that an algorithm is O(N²).

How much this matters depends on N. For a few dozen items the whole sort finishes in a blink, and its simplicity is a real virtue. For thousands of items it becomes a nuisance. A million items would need about 500 billion comparisons, close to six days at a microsecond each. The next hour shows a simple sort that does about half the work, and Part III shows sorts that are far faster.

Summary

  • Sorting arranges data items by a key. People need it to read data comfortably, and binary search needs it before it can work.
  • A program can compare only two items at a time, so every sort is built from two operations: comparing items, and swapping or copying them.
  • The bubble sort compares neighbors from left to right and swaps them whenever the left one is larger. Each pass carries the largest remaining item to the end of the unsorted part.
  • In the C++ version, the outer loop variable out starts at the right end and moves left one cell per pass. The inner loop variable in runs from 0 up to out-1.
  • The bubble sort's invariant is that the items to the right of out are sorted and in their final places.
  • The bubble sort always makes N×(N−1)/2 comparisons, about N²/2. It makes about N²/4 swaps on random data and about N²/2 on inversely sorted data.
  • Both counts grow as N², so the bubble sort is O(N²). Two nested loops that each run about N times are a sign of O(N²).
  • The bubble sort is the slowest sort in the book and also the simplest. It is a reasonable choice only for small amounts of data.

Q&A

Why learn the bubble sort at all, if it's the slowest sort in the book?
It is the easiest sort to understand, which makes it a good place to learn the vocabulary of sorting: passes, comparisons, swaps, invariants and O(N²) analysis. It is also short enough to write from memory. With only a few dozen items, a faster sort would save too little time to notice, and simple code is less likely to hide a bug.
Does the bubble sort run faster when the data is already sorted?
A little, but it stays O(N²). The book's version makes all N×(N−1)/2 comparisons whatever the data looks like, because nothing in its loops checks whether the job is done. Sorted data does save every swap. The early-exit flag described in the last Beyond the book note removes most of the wasted comparisons.
Which costs more, a comparison or a swap?
A swap. A comparison only looks at two values, while a swap moves data three times: into temp, from one cell to the other, and out of temp. When the items are large objects, those moves add up. The insertion sort in Hour 5 gains part of its speed by moving items with single copies instead of swaps.

Quiz

  1. How many comparisons does the bubble sort make when it sorts 10 items?

    Answer

    45. The passes make 9, 8, 7 and so on down to 1 comparison, and 10×9/2 = 45.

  2. After the first pass, what do you know for certain about the item at the right end?

    Answer

    It is the largest item in the array, and it is already in its final position. It bubbled there because it won every comparison once the pass reached it.

  3. What is the invariant of bubbleSort()?

    Answer

    The items to the right of out are sorted and in their final positions.

  4. How many swaps does the bubble sort make on 10 items that start in inverse order?

    Answer

    45. With inversely sorted data every comparison finds a pair out of order, so every one of the 45 comparisons leads to a swap.

  5. What is the bubble sort's Big O efficiency, and why?

    Answer

    O(N²). It makes N×(N−1)/2 comparisons and, on random data, about N²/4 swaps. Both are proportional to N², and Big O ignores the constant factors.

  6. What does the inner loop do during one pass?

    Answer

    It moves in from 0 up to out-1, comparing v[in] with v[in+1] and swapping them when the left one is larger. This carries the largest unsorted item into cell out.

5

Part I · Introducing Data Structures and Algorithms

Hour 5 The Insertion Sort

The insertion sort belongs to the same O(N²) family as the bubble sort, yet it does about half the work on random data and very little work on data that is almost in order. This hour shows how it works, how to code it, and how it handles objects and items with equal keys.

In this hour you'll learn

  • How the insertion sort works on the baseball team, using the idea of partial sorting
  • What the insertSort Workshop applet shows
  • How to write the insertion sort in C++
  • Why it beats the bubble sort, and when it runs in nearly O(N) time
  • How to sort objects, such as people ordered by last name
  • What makes a sort stable, and how the two simple sorts compare

Of the two simple sorts in this book, the insertion sort is the one you would actually choose. It still runs in O(N²) time, but on random data it finishes in roughly half the time the bubble sort needs, and its code is only a little more involved. It is also a favorite finishing tool for faster algorithms. In Hour 14 you'll see quicksort hand its last small jobs to an insertion sort.

Insertion Sort on the Baseball Players

Take the baseball team from Hour 4 again, nine players to be lined up by height. The insertion sort is easiest to understand if you join it in the middle, when the job is already partly done.

Partial Sorting

Imagine a chalk mark on the ground somewhere in the middle of the line. The players to the left of the mark are in order among themselves: each one is taller than the player to his left. They are not necessarily in their final positions, though. Some shorter players are still waiting to the right of the mark, and when their turn comes they will be squeezed in among the sorted group, pushing some of its members to the right.

This differs from the bubble sort. There, the sorted players at the right end were finished and never moved again. Here, the sorted group on the left is only a draft that keeps getting revised as new players join it.

The Marked Player

The player standing just to the right of the mark is the marked player. He and everyone to his right are still unsorted. The next job is to insert him into the right place in the partially sorted group, and it goes like this.

  1. The marked player steps out of the line and waits nearby. In a program, this means copying him to a temporary variable. His old spot is now an empty gap.
  2. Look at the sorted player just left of the gap. If he is taller than the marked player, he shifts one place right into the gap, and the gap moves one place left.
  3. Repeat step 2 with the next player to the left, and keep shifting until the player left of the gap is shorter than the marked player, or until the gap reaches the left end of the line.
  4. The marked player steps into the gap.
146895273marked
The five players left of the mark are partially sorted. The marked player, height 5, is next to be inserted.
140689273temp5gap
The marked player has stepped out to temp. The taller players 9, 8 and 6 have each shifted one place right, moving the gap to position 2. Height 4 is shorter than 5, so the shifting stops.
145689273marked
The marked player steps into the gap. The partially sorted group has grown to six players, and the mark moves one place right.

Inserting this one player took four comparisons (with heights 9, 8, 6 and 4) and three shifts. Now the partially sorted group is one player bigger and the unsorted group one player smaller. Move the mark one place to the right, so the player with height 2 becomes the marked player, and do it all again. He is shorter than everyone in the group except the player of height 1, so five players shift right and he lands in position 1.

The process has to start somewhere, and it starts in the simplest possible way. A single player is always in order with himself, so the mark begins just after the first player. The first marked player is the one in position 1. The sort ends when the mark has passed the last player and everyone has been inserted. If you run the whole insertion sort on the nine players from Hour 4, it takes 28 comparisons and 22 shifts, where the bubble sort needed 36 comparisons and 22 swaps.

The insertSort Workshop Applet

The insertSort applet uses the same bar display and the same buttons as the bubbleSort applet. With 100 bars, press Run and watch the left end of the display. A sorted slope forms there and widens steadily to the right, while the unsorted bars wait on the right side. Each new bar gets slipped into its place somewhere in the slope, which is the partial sorting you just saw with the players.

With 10 bars, Step shows the details. An arrow labeled outer marks the marked bar, playing the role of the chalk mark. An arrow labeled inner shows where the shifting is happening, which is the position of the gap. At the start of each pass the marked bar is copied into temp and drawn off to one side, apart from the others, while the taller bars shift right one at a time. When the gap reaches the right spot, the bar in temp is copied into it. The counters report comparisons and copies. Copies take the place of the bubble sort's swaps, and they are cheaper, as you'll see shortly.

Implementing the Insertion Sort in C++

The program insertSort.cpp uses a class named ArrayIns. It is the ArrayBub class from Hour 4 with two changes. The swap() helper is gone, because the insertion sort never swaps, and bubbleSort() is replaced by insertionSort(). The data (a vector<double> named v and the count nElems), the constructor, insert() and display() are unchanged.

insertSort.cpp The insertionSort() member function of ArrayIns
void ArrayIns::insertionSort()
   {
   int in, out;
   // out is the marker; cells to its left are partially sorted
   for(out=1; out<nElems; out++)
      {
      // the marked item steps out of line
      double temp = v[out];
      // the gap starts where the marked item stood
      in = out;
      // while the item left of the gap is bigger than temp,
      // slide it right into the gap; the gap moves left
      while( in>0 && v[in-1] > temp )
         {
         v[in] = v[in-1];
         --in;
         }
      // the marked item steps into the gap
      v[in] = temp;
      }
   }

The outer for loop moves out, the marker, from 1 up to the last occupied cell. It starts at 1 because a single item in cell 0 counts as partially sorted already. At the start of each pass the marked item v[out] is copied into temp, and in is set equal to out. From then on, in is the position of the gap.

The inner while loop does the shifting. It keeps going as long as two things are true: in is greater than 0, so a cell still exists to the left of the gap, and the item in that cell, v[in-1], is larger than temp. Each time around, the loop copies v[in-1] one cell right into the gap and decrements in, which moves the gap one cell left. When either condition fails, the gap is exactly where temp belongs, and the last statement of the pass copies temp into v[in].

v00111222443554775886997668339inout
The pass with out = 8, on the data from main(). temp holds 66. The items 99, 88 and 77 are larger, so each is copied one cell right (arrows). The loop stops with in at 5, because 55 is smaller than 66, and temp is stored in cell 5.

The insertion sort has an invariant of its own. At the end of each pass, once temp has been dropped into place, the items in cells 0 through out are partially sorted. After out moves right for the next pass, the same fact reads this way: every item with an index less than out is partially sorted. When out finally moves past the last cell, "every item to its left" means the whole array, so the whole array is sorted.

The main() function is the same as in bubbleSort.cpp, except that it creates an ArrayIns and calls insertionSort(). It inserts the same ten numbers, displays them, sorts them and displays them again, with the same result.

77 99 44 55 22 88 11 0 66 33
0 11 22 33 44 55 66 77 88 99

On these ten numbers the insertion sort makes 36 comparisons and 31 shifts. The bubble sort makes 45 comparisons and 31 swaps on the same data. The number of moves comes out the same, but each of the insertion sort's shifts is a single copy, while each of the bubble sort's swaps is three copies.

Efficiency of the Insertion Sort

Count the comparisons pass by pass. On the first pass the marked item can be compared with at most one item, on the second with at most two, and so on, up to at most N−1 comparisons on the last pass. The largest possible total is 1+2+3+…+(N−1) = N×(N−1)/2, the same sum you met with the bubble sort.

The difference is that the insertion sort usually stops early. On each pass, the while loop quits as soon as it finds an item smaller than temp. In random data that happens, on average, about halfway through the partially sorted group, so a typical pass makes only about half of its possible comparisons. That brings the total down to about N×(N−1)/4, roughly N²/4.

The number of copies is roughly the same as the number of comparisons, since nearly every comparison leads to one shift. A copy is much cheaper than a swap, which needs three copies. So on random data the insertion sort makes about half the comparisons of the bubble sort and moves its data far more cheaply. In practice it runs about twice as fast. It is still an O(N²) algorithm, though, because N²/4 grows as N² and the 4 disappears in Big O notation.

Already Sorted and Almost Sorted Data

The insertion sort shines when the data is already in order, or nearly so. If the data is sorted, the condition in the while loop is false the first time it is tested on every pass, because the item left of the gap is always smaller than temp. No shifting ever happens. The inner loop shrinks to a single comparison, the outer loop makes N−1 passes, and the whole sort takes N−1 comparisons. That is O(N) time.

If the data is almost sorted, with only a few items out of place, most passes still end after one comparison, and only the few misplaced items trigger any shifting. The running time is close to O(N). That makes the insertion sort an excellent choice for data that is only slightly out of order, such as a sorted list with a few new items tacked onto the end.

Inversely Sorted Data

The worst case is data in inverse order. Every marked item is smaller than everything in the partially sorted group, so every pass compares and shifts all the way to the left end. The sort makes every possible comparison, all N×(N−1)/2 of them, and a shift for each one. Lafore's verdict is that in this case the insertion sort runs no faster than the bubble sort, since both make the maximum number of comparisons.

Work done sorting 100 items
Starting orderBubble comparisonsBubble swapsInsertion comparisonsInsertion copies
Random4,950about 2,475about 2,570about 2,670
Already sorted4,950099198
Inversely sorted4,9504,9504,9505,148

The insertion sort's copy count includes two copies per pass for the marked item itself, one into temp and one back out, which is why sorted data still costs 2×99 = 198 copies. The random rows are averages. They sit slightly above N²/4 = 2,500 because every pass that stops early spends one extra comparison discovering where to stop.

Sorting Objects

For simplicity, the sorts so far have worked on plain numbers. Real programs usually sort records, such as personnel files, parts lists or customer accounts. Each record is an object with several fields, and one field serves as the key. The program objectSort.cpp shows how little the insertion sort has to change to handle this.

Its data items are objects of a class named Person, with three fields: a last name, a first name and an age. The key is the last name. The array class, ArrayInOb, keeps its data in a vector<Person*>, a vector of pointers to Person objects. Its insert() member creates each new person with new and stores the object's address in the next free cell, and its destructor deletes the objects when the array goes away.

objectSort.cpp The Person class
class Person
   {
   private:
      string lastName;
      string firstName;
      int age;

   public:
      Person(string last, string first, int a) :
         lastName(last), firstName(first), age(a)
         { }

      // one person per line: last name, first name, age
      void displayPerson()
         {
         cout << "   " << lastName << ", " << firstName
              << " (" << age << ")" << endl;
         }

      // the sort key
      string getLast()
         { return lastName; }
   };
objectSort.cpp insertionSort() in ArrayInOb, keyed on last names
void ArrayInOb::insertionSort()
   {
   int in, out;
   for(out=1; out<nElems; out++)
      {
      // hold on to the marked person's address
      Person* temp = v[out];
      in = out;
      // string's > operator compares the names
      // alphabetically, one letter at a time
      while( in>0 && v[in-1]->getLast() > temp->getLast() )
         {
         v[in] = v[in-1];
         --in;
         }
      v[in] = temp;
      }
   }

The only real change is the comparison. Because v[in-1] and temp are pointers, the code uses the -> operator to call getLast() on the objects they point to. The two strings that come back are compared with >, which the C++ string class defines to compare alphabetically. Everything else is as before, except that temp is now a Person* and each shift copies a pointer.

Storing pointers keeps the moves cheap. Copying a pointer copies a single address. Copying a whole Person on every shift would mean copying two strings and an int. The sort rearranges the vector's cells, and the Person objects themselves never move.

points topoints topoints tov[0]v[1]v[2]EvansPatty · 24SmithDoc · 59SmithLorraine · 37
The vector holds only addresses. Each cell points to a Person object stored elsewhere in memory, so the sort shuffles small pointers and leaves the people where they are.

Lexicographical Comparisons

The string class compares strings lexicographically, a formal word for the way a dictionary orders words. It compares the first characters of the two strings. If they differ, the string whose character comes earlier in the alphabet comes first. If they match, it moves on to the second characters, and so on. If one string runs out while every character so far has matched, the shorter string comes first, which is why "Smith" sorts ahead of "Smithers."

So "Smith" comes before "Stimson": both start with S, and at the second letter m comes before t. "Vang" comes before "Velasquez" because a comes before e. Two copies of "Smith" are equal, so neither is greater than the other, and that fact becomes important in the next section. Here is the program's output for ten people.

Before sorting:
   Evans, Patty (24)
   Smith, Doc (59)
   Smith, Lorraine (37)
   Smith, Paul (37)
   Yee, Tom (43)
   Hashimoto, Sato (21)
   Stimson, Henry (29)
   Velasquez, Jose (72)
   Vang, Minh (22)
   Creswell, Lucinda (18)
After sorting:
   Creswell, Lucinda (18)
   Evans, Patty (24)
   Hashimoto, Sato (21)
   Smith, Doc (59)
   Smith, Lorraine (37)
   Smith, Paul (37)
   Stimson, Henry (29)
   Vang, Minh (22)
   Velasquez, Jose (72)
   Yee, Tom (43)

The people come out in alphabetical order of last name. Look at the three Smiths. They went in as Doc, Lorraine and Paul, and they come out in that same order.

Another Feature of Sorting Algorithms: Stability

Sometimes it matters what a sort does with items whose keys are equal. Suppose a company's employee records are already sorted alphabetically by last name. Now you want them sorted by zip code, and within each zip code you want the employees to stay in alphabetical order. You need a sort that leaves equal-key items in the order it found them. Such a sort is called stable.

Six employees, before and after a stable sort by zip code
Before: sorted by nameZipAfter: sorted by zipZip
Arnold30303Arnold30303
Brooks98101Castillo30303
Castillo30303Fong30303
Dunn60601Dunn60601
Ellis98101Brooks98101
Fong30303Ellis98101

In the table, the three employees in zip code 30303 come out as Arnold, Castillo, Fong, still alphabetical. An unstable sort could legally produce Fong, Arnold, Castillo instead, because all three have the same key, and you would have to sort each zip code by name all over again.

The bubble sort and the insertion sort are both stable, and the reason lies in their comparisons. The bubble sort swaps two neighbors only when the left one is strictly greater, so two equal items never trade places. The insertion sort shifts an item only while it is strictly greater than temp. When the marked item meets an equal key, the shifting stops and the marked item lands just to the right of it, the same side it started on. The three Smiths in objectSort.cpp kept their order for exactly this reason.

Comparing the Simple Sorts

There is seldom a reason to use the bubble sort. Its one strength is that it is so simple you can write it from memory without a mistake. That makes it tolerable for a small amount of data when you need something working in a hurry. For anything bigger it is too slow.

The insertion sort is the best of the simple sorts. It is about twice as fast as the bubble sort on random data, and it is very fast on data that is almost sorted, where it approaches O(N). For a small amount of data, or data that is nearly in order already, it is usually the right choice.

Both sorts work in place. Besides the original array they need only one extra variable, temp, to hold an item while it moves, and they never need a second array. So memory is no concern with either one. When the amount of data grows large, turn to the advanced sorts of Part III, which are much faster.

The two simple sorts side by side
PropertyBubble sortInsertion sort
Comparisons, random dataabout N²/2about N²/4
Data moves, random dataabout N²/4 swaps (3 copies each)about N²/4 copies
Already sorted dataN²/2 comparisons, no swaps, O(N²)N−1 comparisons, O(N)
Inversely sorted dataN²/2 comparisons, N²/2 swapsN²/2 comparisons, N²/2 copies
Overall efficiencyO(N²)O(N²)
StableYesYes
Extra memoryOne temporary variableOne temporary variable

Summary

  • The insertion sort splits the data into a partially sorted group on the left and unsorted items on the right. Partially sorted items are in order among themselves but not yet in their final places.
  • Each pass copies the marked item into temp, shifts the larger items of the sorted group one place right, and drops temp into the gap that opens up.
  • In C++, the outer loop moves out from 1 upward. The inner while loop shifts items while in>0 and v[in-1] is greater than temp.
  • The invariant is that at the start of every pass, the items to the left of out are partially sorted.
  • On random data the insertion sort makes about N²/4 comparisons and about as many copies. That is roughly twice as fast as the bubble sort, but still O(N²).
  • Already sorted data needs only N−1 comparisons, O(N), and almost sorted data is nearly as fast. Inversely sorted data is the worst case.
  • Objects are sorted the same way as numbers. Only the comparison changes, for example to compare last names with the string class's lexicographical > operator.
  • A stable sort keeps equal-key items in their original order. Both simple sorts are stable, and both sort in place with a single extra variable.

Q&A

If the insertion sort is better, why did the book start with the bubble sort?
The bubble sort is easier to follow, so it was the natural place to introduce comparisons, swaps, passes, invariants and O(N²) analysis. With those ideas in hand, the insertion sort's extra machinery (the marker, temp and the shifting loop) is much easier to absorb.
Is the insertion sort ever slower than the bubble sort?
Not by comparisons. Its largest possible count, N×(N−1)/2, is the count the bubble sort always makes, so the insertion sort can at worst tie. It ties only when every marked item has to travel to the far left end of the sorted group, or next to it, as happens with inversely sorted data. On typical data it makes far fewer comparisons.
Why does objectSort.cpp store pointers instead of Person objects?
So that every shift copies a small address instead of a whole object with two strings inside it. The people stay put in memory, and only the vector of pointers gets rearranged.
When does stability matter?
When you sort by one key after another, as in the zip code example, or when the existing order of equal items means something, such as the order in which they arrived. If ties never matter to anyone, stability is irrelevant.

Quiz

  1. Why does the insertion sort run about twice as fast as the bubble sort on random data?

    Answer

    It makes about half as many comparisons (about N²/4 against N²/2), and it moves data with single copies where the bubble sort uses swaps of three copies each.

  2. What is the insertion sort's Big O efficiency on data that is already sorted, and why?

    Answer

    O(N). The while test fails immediately on every pass, so each of the N−1 passes makes one comparison and no shifts.

  3. What does "partially sorted" mean?

    Answer

    The items are in order among themselves, but they are not necessarily in their final positions, because unsorted items may still be inserted between them.

  4. In insertionSort(), what does temp hold during a pass?

    Answer

    The marked item, copied from v[out] at the start of the pass. It waits there while larger items shift right, and then it is copied into the gap.

  5. What does it mean for a sort to be stable, and are the two simple sorts stable?

    Answer

    A stable sort keeps items with equal keys in their original relative order. Both the bubble sort and the insertion sort are stable, because each moves an item past another only when it is strictly greater.

  6. How many comparisons does insertionSort() make on 10 items that are already in order?

    Answer

    9, one for each pass, since every while test fails at once.

6

Part II · Abstract Data Types

Hour 6 Stacks

Part II turns from structures that store data to structures that help a program get work done. The stack is the simplest of them. It lets you reach only the item you added most recently, and that single restriction turns out to be remarkably useful.

In this hour you'll learn

  • How the structures of Part II differ from arrays
  • What a stack is, and what push, pop and peek do
  • How to build a stack class in C++ on top of a vector
  • How a stack reverses the letters of a word
  • How a stack checks that braces, brackets and parentheses match
  • Why stack operations take O(1) time

A Different Way to Think About Data Structure

The arrays of Part I are the kind of structure you would use in a database program. They hold records that matter in their own right, such as personnel files, inventory lists or financial data, and they let you insert, search for and delete those records. The linked lists and trees of later hours serve the same kind of purpose. The structures of Part II (stacks, queues and priority queues) are different in three ways.

Programmer's Tools

In a database program, the data is the point. The records exist before the program runs and are still there after it stops. Stacks and queues are more often working tools. A program sets one up to help it carry out some task, such as checking a line of code, and throws it away when the task is finished. Their lifetimes are short compared with those of database records.

Restricted Access

In an array you can reach any item directly if you know its index, or find it by searching. In a stack or a queue, access is restricted. Only one item can be read or removed at any given moment: the top item of a stack, or the front item of a queue. The other items are there, but the structure's interface gives you no way to get at them until the accessible item has been removed. This limit is deliberate. It is exactly what makes these structures fit the jobs they do.

More Abstract

Stacks, queues and priority queues are also more abstract than arrays. They are defined mainly by their interface, the set of operations a user can perform, such as putting an item on a stack or taking one off. The mechanism underneath is hidden from the user. In this hour the stack is built on a vector, but it could just as well be built on a linked list, and a program that uses the stack would work the same way. This separation of interface from mechanism leads to the idea of abstract data types, the subject of Hour 9.

callsbuilt onYour programStack interfacepush · pop · peekHidden mechanismarray or linked list
A program talks only to the stack's interface. What stores the items underneath, an array or a linked list, stays out of sight and can be swapped without changing the program.

Understanding Stacks

A stack allows access to only one data item, the last one inserted. To reach the item below it, you must first remove the top one. Remove that, and the next one down becomes accessible, and so on down to the bottom.

That sounds limiting, yet a great many algorithms depend on it. Later in this hour a stack checks whether the parentheses, braces and brackets in a line of source code are properly matched. Stacks are also used to parse arithmetic expressions such as 3×(4+5) and to traverse the nodes of a tree. They even live inside the microprocessor. When a program calls a function, the address to return to and the function's arguments are pushed onto a stack, and when the function returns they are popped off again.

Three Everyday Stacks

Lafore gives three everyday pictures of a stack. The first is a pile of mail. In many households, letters get tossed onto a pile on the hall table as they arrive. When someone finally sits down to deal with the pile, they start at the top: open the top letter, pay the bill or throw away the ad, then pick up the next letter down, and so on until the table is bare. The letter on top is always the newest arrival. The weakness of this system shows when the pile never quite gets finished, because the letters at the bottom can sit untouched for weeks.

The second picture is a workday full of interruptions. You are deep in a long project, call it A, when a coworker asks for help with task B. While you are helping with B, someone from accounting calls about matter C. You deal with C first, then go back to B, and only when B is done do you return to A. Each interruption is placed on top of the work already in progress, and each finished job uncovers the one beneath it.

tasksA0B1C23top
The interrupted workday as a stack. Project A is at the bottom and call C is on top. You always work on the top task, and finishing it brings you back to the one below.

The third picture is the spring-loaded stack of plates in a cafeteria. Clean plates are pressed down onto the top of the pile, and a diner can take only the top plate. When one is lifted off, the spring pops the next one up into reach.

Push, Pop and Peek

Stacks come with their own vocabulary. Placing an item on the top of the stack is called pushing it. Removing the top item is called popping it. Reading the top item without removing it is called peeking. The top of the stack is the position of the most recently pushed item, and the bottom is the position of the first item pushed. Because the last item in is always the first item out, a stack is called a LIFO structure.

The Stack Workshop Applet

The Stack Workshop applet draws an array of cells stacked vertically, with the bottom of the stack at the bottom of the display. An arrow labeled top points at the highest occupied cell. Four buttons do the work. New creates a new, empty stack. Push asks you for a value and places it on top. Pop removes the top item and shows you its value. Peek shows you the top item and leaves it where it is. If you try to push onto a stack that is already full, or pop from one that is empty, the applet shows a warning instead of carrying out the operation.

stack200401602345top
A stack holding three items. The top arrow points at 60, the last item pushed.
stack20040160280345top
After pushing 80, the top moves up one cell and 80 is stored there.
stack2004012345top
Two pops return 80 and then 60, the reverse of the order in which they went in. The top is now 40.

Implementing a Stack in C++

The program stack.cpp builds a stack class named StackX on top of a vector. It has three data members: maxSize, the most items the stack can hold; stackVect, a vector<double> whose cells hold the items, with the bottom of the stack at index 0; and top, the index of the item currently on top.

stack.cpp The StackX class
class StackX
   {
   private:
      // the most items the stack can hold
      int maxSize;
      // the cells; the bottom of the stack is index 0
      vector<double> stackVect;
      // index of the top item; -1 while the stack is empty
      int top;

   public:
      StackX(int s) : maxSize(s), top(-1)
         { stackVect.resize(maxSize); }

      // ++top happens first: step up to a free cell, then store j
      void push(double j)
         { stackVect[++top] = j; }

      // top-- happens after the read: return the top item, then step down
      double pop()
         { return stackVect[top--]; }

      // read the top item but leave it where it is
      double peek()
         { return stackVect[top]; }

      bool isEmpty()
         { return top == -1; }

      bool isFull()
         { return top == maxSize-1; }
   };

The constructor sizes the vector and sets top to −1. Since top is the index of the top item, an empty stack has no valid value for it, and −1 is a natural choice because it is one below the first cell. It also makes push() work the same way every time. The very first push raises top from −1 to 0 and stores the item in cell 0, exactly as later pushes raise it from 0 to 1, from 1 to 2, and so on.

The two key members each fit on one line, and the position of the ++ and -- operators is what makes them work. In push(), the expression ++top increments top before its value is used, so top first moves up to the empty cell above the old top item, and then the new item is stored there. In pop(), the expression top-- uses the current value of top first, so the item on top is fetched, and only then does top drop by one to point at the item below. Swap the operators around and both functions break: push() would overwrite the current top item, and pop() would return the wrong one.

peek() returns the top item without changing top. isEmpty() reports whether top is −1, and isFull() reports whether it has reached the last cell, maxSize-1.

The main() function creates a stack with room for ten items, pushes four numbers, and then pops and prints items until the stack is empty.

stack.cpp main() pushes four items and pops them all
int main()
   {
   // a stack with room for ten numbers
   StackX theStack(10);

   theStack.push(20);
   theStack.push(40);
   theStack.push(60);
   theStack.push(80);

   // pop until nothing is left, printing each item
   while( !theStack.isEmpty() )
      {
      double value = theStack.pop();
      cout << value << " ";
      }
   cout << endl;
   return 0;
   }
80 60 40 20

The numbers come out in the reverse of the order in which they went in. The last item pushed, 80, is the first one popped. That is LIFO in action.

Error Handling

Notice that main() calls isEmpty() before every pop. StackX leaves that responsibility to the code that uses it: you are expected to call isFull() before pushing and isEmpty() before popping. The class itself doesn't check.

A sturdier class would do the checking inside push() and pop(). It could print an error message and refuse the operation, as in the version of push() below, or it could throw an exception and let the calling code decide what to do.

stack.cpp A push() that protects itself
// a more careful push: refuse to overfill the stack
void push(double j)
   {
   if( isFull() )
      {
      cout << "Error: the stack is full" << endl;
      return;
      }
   stackVect[++top] = j;
   }

Stack Example 1: Reversing a Word

The first real job for a stack is a tiny one, reversing the letters of a word. Push the letters one at a time, from first to last, and then pop them all. Because a stack hands items back in the opposite order, the letters come out backward. Push the letters of "part" and you pop "trap."

lettersp0a1r2t34top
After all four letters of "part" have been pushed, t is on top. Popping them gives t, r, a, p.

The program reverse.cpp uses the same StackX class with one change: its vector holds char values instead of double, so push() takes a char and pop() and peek() return one. The work is done by a class named Reverser. Its constructor stores the input string, and its doRev() member function produces the reversed output string.

reverse.cpp The doRev() member function of Reverser
string doRev()
   {
   int len = input.length();
   // one cell per letter is all the room needed
   StackX theStack(len);

   // push the letters from first to last
   for(int j=0; j<len; j++)
      theStack.push( input[j] );

   // pop them all; they come out last letter first
   output = "";
   while( !theStack.isEmpty() )
      output += theStack.pop();
   return output;
   }

doRev() first creates a stack exactly as big as the word, since it will never hold more letters than that. The for loop pushes every character of input. The while loop then pops characters until the stack is empty, appending each one to output with the string class's += operator. The finished output is returned to the caller.

In main(), a loop asks the user for a word, hands it to a new Reverser, and prints the result. Pressing Enter without typing anything ends the program. A short session looks like this.

Type a word (Enter alone quits): part
Backward: trap
Type a word (Enter alone quits): drawer
Backward: reward
Type a word (Enter alone quits):

Stack Example 2: Delimiter Matching

A more serious job for a stack is checking the delimiters in a line of text, the way a compiler checks a program's source code. The delimiters are braces { and }, brackets [ and ], and parentheses ( and ). Each opening delimiter must be matched by a closing delimiter of the same type. The matching also has to respect nesting, which means a delimiter opened later must be closed before any delimiter opened earlier. The book uses these examples.

Which strings have correctly matched delimiters
StringVerdict
c[d]Correct
a{b[c]d}eCorrect
a{b(c]d}eWrong. The ] arrives while ( is the delimiter waiting to be closed.
a[b{c}d]e}Wrong. Nothing matches the final }.
a{b(c)Wrong. Nothing matches the opening {.

Opening Delimiters on the Stack

The checking program reads the string one character at a time, from left to right, and follows three rules.

  • When it reads an opening delimiter, it pushes it onto a stack.
  • When it reads a closing delimiter, it pops the top item off the stack and checks that the two delimiters are the same type. A mismatch is an error. If the stack is empty, there is nothing to pop, which is also an error, because the closer has no partner.
  • Any other character is ignored.

When the whole string has been read, the stack should be empty. If an opening delimiter is still on it, that delimiter was never closed, and that is an error too. Here is the stack at every step of checking the correct string a{b(c[d]e)f}.

The stack while checking a{b(c[d]e)f}
Character readActionStack, bottom to top
aignore(empty)
{push{
bignore{
(push{ (
cignore{ (
[push{ ( [
dignore{ ( [
]pop [, a match{ (
eignore{ (
)pop (, a match{
fignore{
}pop {, a match(empty)
openers{0(1[234top
The stack after reading a{b(c[. Three openers wait, with [ on top, so the next closing delimiter must be ].

A stack suits this job because the delimiter that must close next is always the one opened most recently, and that is exactly the one on top. The stack remembers the open delimiters in the very order in which they have to be checked. Simply counting openers and closers of each type would not be enough. The string ([)] has one of each and is still wrong. With a stack the algorithm stays short and clear, which is what Lafore means by calling the stack a conceptual aid.

C++ Code for Delimiter Matching

The program brackets.cpp uses the char version of StackX again. The checking is done by a class named BracketChecker, which stores the input string. A small private helper, matches(), reports whether an opener and a closer are the same kind, and the check() member function does the rest.

brackets.cpp The BracketChecker class
class BracketChecker
   {
   private:
      string input;

      // true when the opener and the closer are the same kind
      bool matches(char opener, char closer)
         {
         return (opener=='{' && closer=='}') ||
                (opener=='[' && closer==']') ||
                (opener=='(' && closer==')');
         }

   public:
      BracketChecker(string in) : input(in)
         { }
      void check();
   };
brackets.cpp The check() member function
void BracketChecker::check()
   {
   int len = input.length();
   // at worst, every character is an opener
   StackX theStack(len);
   bool ok = true;

   for(int j=0; j<len; j++)
      {
      char ch = input[j];
      switch(ch)
         {
         // an opener waits on the stack for its partner
         case '{':  case '[':  case '(':
            theStack.push(ch);
            break;

         // a closer must pair with the most recent opener
         case '}':  case ']':  case ')':
            if( theStack.isEmpty() )
               {
               cout << "Error: " << ch << " at position " << j
                    << " has no opener" << endl;
               ok = false;
               }
            else
               {
               char chx = theStack.pop();
               if( !matches(chx, ch) )
                  {
                  cout << "Error: " << ch << " at position " << j
                       << " does not match " << chx << endl;
                  ok = false;
                  }
               }
            break;

         // everything else is ignored
         default:
            break;
         }
      }

   // openers left on the stack were never closed
   if( !theStack.isEmpty() )
      {
      cout << "Error: " << theStack.peek() << " is never closed" << endl;
      ok = false;
      }
   if(ok)
      cout << "Delimiters match" << endl;
   }

check() creates a stack as long as the input string, because in the worst case every character could be an opener. The for loop reads the characters, and a switch statement sorts each one into one of three cases. Openers are pushed. For a closer, the code first checks for an empty stack, which means the closer has no partner. Otherwise it pops the most recent opener into chx and asks matches() whether the two belong together. Every other character falls through to the default case and is ignored.

After the loop, anything still on the stack is an opener that was never closed. The flag ok records whether any error turned up, so the function can announce success when none did. The error messages give the position of the offending character, counting from 0. As in reverse.cpp, main() repeatedly asks for a line of text and hands it to a new BracketChecker. Here is a session using the book's examples.

Type a string (Enter alone quits): a{b(c[d]e)f}
Delimiters match
Type a string (Enter alone quits): a{b(c]d}e
Error: ] at position 5 does not match (
Type a string (Enter alone quits): a[b{c}d]e}
Error: } at position 9 has no opener
Type a string (Enter alone quits): a{b(c)
Error: { is never closed
Type a string (Enter alone quits):

Efficiency of Stacks

Items are pushed onto and popped off a StackX in constant time, O(1). Neither operation compares items or moves any other item. A push adds one to top and copies one item into a cell. A pop copies one item out and subtracts one from top. The time doesn't depend on how many items the stack holds, whether that is three or three million. peek(), isEmpty() and isFull() are O(1) as well.

That speed, together with the tiny interface, is why stacks turn up inside so many algorithms. The price is the restricted access, since you can never reach an arbitrary item. For a programmer's tool that is usually a good trade.

Summary

  • The structures of Part II are programmer's tools with short lives. They restrict access to one item at a time, and they are more abstract than arrays, being defined by their interface while the mechanism underneath stays hidden.
  • A stack allows access only to the most recently inserted item. It is a last-in, first-out (LIFO) structure.
  • Push places an item on top, pop removes the top item, and peek reads the top item without removing it.
  • StackX keeps its items in a vector. top holds the index of the top item and starts at −1. push() uses ++top and pop() uses top--.
  • StackX expects its user to call isFull() before pushing and isEmpty() before popping. A sturdier class would check inside push() and pop().
  • Pushing the letters of a word and then popping them reverses the word.
  • To check delimiters, push each opener, pop and compare on each closer, ignore everything else, and make sure the stack ends up empty.
  • Push and pop take O(1) time, no matter how many items the stack holds.

Q&A

Why use a stack when an array can do everything a stack can do, and more?
Because the restriction helps. A stack's interface states the intent of the code and makes it impossible to disturb items that should stay put. When a problem is LIFO by nature, as delimiter matching is, the stack version of the solution is shorter and easier to trust than one that juggles array indices.
What happens if I pop an empty StackX?
It reads the nonexistent cell stackVect[-1] and leaves top at −2. The result is unpredictable. Always call isEmpty() first, or build the check into pop().
Does a stack have to be built on an array?
No. A linked list works just as well, and the program using the stack can't tell the difference, because it sees only push, pop and peek. Hour 9 builds the same stack both ways.
Can a stack hold things other than numbers?
Yes. reverse.cpp and brackets.cpp simply change the vector's element type to char, and the same idea works for strings or objects. C++ templates can generalize a class like this to any type, although this hour's programs keep things simple by editing the type directly.

Quiz

  1. What does LIFO stand for, and what does it mean?

    Answer

    Last-in, first-out. The item inserted most recently is the first one removed.

  2. You push 1, 2 and 3 onto an empty stack and then pop until it is empty. In what order do the items come out?

    Answer

    3, 2, 1.

  3. What is the value of top in an empty StackX, and why that value?

    Answer

    −1. An empty stack has no top item, so top sits one below the first cell. The first push then raises it to 0 and stores the item in cell 0.

  4. While checking a{b(c, what is on the stack after the last character has been read?

    Answer

    { at the bottom and ( on top. The string ends with both still open, so the checker reports an error.

  5. What is the Big O efficiency of push and pop?

    Answer

    O(1). Each one changes top and copies a single item, whatever the size of the stack.

  6. What should the delimiter checker do when it reads a closing delimiter and the stack is empty?

    Answer

    Report an error. The closing delimiter has no opening delimiter to match.

7

Part II · Abstract Data Types

Hour 7 Queues and Priority Queues

In a queue the first item in is the first item out, just as in a line of people waiting their turn. A priority queue bends that rule so the most urgent item always leaves first. This hour builds both in C++ and shows the wraparound trick that keeps an array-based queue fast.

In this hour you'll learn

  • What a queue is and where queues turn up
  • Why an array-based queue needs to wrap around, and how a circular queue works
  • How to write a Queue class in C++
  • What a deque is
  • What a priority queue is, and how an ordered array implements one
  • How fast each of these structures is

Queues

In Britain, a queue is a line of people waiting for something. In computing, a queue is a data structure that works like such a line. It resembles a stack, except that the first item inserted is the first item removed. That rule is called FIFO, for first-in, first-out. A stack, as you saw in Hour 6, works the other way around, last-in, first-out.

Think of the line at a movie theater. The first person to join it is the first to buy a ticket and go in, and the last person to join is the last one served. Nobody at the back gets served before the people ahead of them.

Queues turn up wherever things must wait their turn. Airplanes wait in a queue on the taxiway for their turn to take off. Data packets wait in queues for their turn to be sent across the Internet. A printer keeps a queue of print jobs and prints them in the order they arrived. Even your keyboard has one. A typeahead buffer holds your keystrokes when you type faster than a program can read them, so none are lost and they reach the program in the order you typed them.

Queues are also used to model real-world waiting lines in simulation programs, such as customers lining up at a bank teller's window or cars waiting at a toll booth. By watching how long the simulated lines grow, a planner can estimate, say, how many tellers are needed.

The names of the queue operations aren't standardized. Adding an item is called insert, put or enqueue, and the item goes in at the back of the queue, called the rear or tail. Taking an item out is called remove, get or dequeue, and the item comes from the front or head. Peek reads the front item without removing it. This hour uses insert, remove and peek, with front and rear.

The Queue Workshop Applet

The Queue Workshop applet shows a queue stored in an array, with one arrow labeled front and another labeled rear. Ins asks for a value and adds it at the rear. Rem removes the item at the front and shows its value. Peek shows the front item without removing it. New creates a new, empty queue.

Watch the arrows as you work. Each insertion moves rear up one cell, and each removal moves front up one cell and leaves an empty cell behind it. After a few insertions and removals, the whole group of items has crept up the array, with empty cells below front. Soon rear reaches the top cell, and the queue seems unable to take another item, even though there are empty cells at the bottom of the array.

One cure would be to shift all the remaining items down to the bottom of the array after every removal, the way a line of people shuffles forward when the person at the head leaves. That works, but it is slow, because every removal would have to move every item still waiting. The better cure is to let the arrows wrap around.

A Circular Queue

When rear reaches the last cell and another item arrives, rear jumps back to cell 0. As long as the queue isn't full, that cell is free, because its item has already left. In effect the two ends of the array are joined into a ring. This is called wraparound, and a queue that uses it is called a circular queue, or a ring buffer.

After rear wraps around, it sits below front, the reverse of the usual arrangement. The items now form two broken sequences. One runs from front up to the top of the array, and the other continues from cell 0 up to rear. Front wraps around in the same way. When it moves past the last cell, it continues from cell 0, and the queue becomes one unbroken sequence again.

012333444555666789rearfrontwraps around
A queue in its normal state. The items run from front (cell 3) up to rear (cell 6).
01234555666777888999rearfrontwraps around
After 33 and 44 are removed and 77, 88 and 99 inserted, rear sits in the last cell. Cells 0 to 4 are empty, but without wraparound there is nowhere to put a new item.
110221234555666777888999rearfrontwraps around
Inserting 11 and 22 sends rear around to cell 0 and then cell 1. The queue now runs from front up to cell 9 and continues from cell 0 to rear.

C++ Code for a Queue

The program queue.cpp implements a circular queue in a class named Queue. Besides the vector queVect and its capacity maxSize, the class keeps three integers: front, the index of the item that will leave next; rear, the index of the item that arrived most recently; and nItems, the number of items waiting.

queue.cpp The Queue class
class Queue
   {
   private:
      int maxSize;
      vector<double> queVect;
      // index of the item that will leave next
      int front;
      // index of the item that arrived most recently
      int rear;
      // how many items are waiting
      int nItems;

   public:
      Queue(int s) : maxSize(s), front(0), rear(-1), nItems(0)
         { queVect.resize(maxSize); }

      // add an item at the rear
      void insert(double j)
         {
         // rear in the last cell? send it back to just before cell 0
         if(rear == maxSize-1)
            rear = -1;
         // step rear forward, then store the item there
         rear++;
         queVect[rear] = j;
         nItems++;
         }

      // take away the item at the front
      double remove()
         {
         // read the front item, then step front forward
         double temp = queVect[front];
         front++;
         // stepped past the last cell? wrap around to cell 0
         if(front == maxSize)
            front = 0;
         nItems--;
         return temp;
         }

      double peekFront()
         { return queVect[front]; }
      bool isEmpty()
         { return nItems == 0; }
      bool isFull()
         { return nItems == maxSize; }
      int size()
         { return nItems; }
   };

The constructor starts front at 0 and rear at −1, the same trick StackX used for top. The first insertion moves rear up to 0 and stores the item there, which is also where front is waiting.

insert() handles wraparound before it does anything else. If rear is already in the last cell, it is set back to −1, so the increment that follows carries it to cell 0. Then rear moves up one cell, the item is stored there, and nItems goes up by one. remove() works the other way around. It copies the item at front into temp and moves front up one cell. If that step has carried front past the last cell, it wraps back to 0. Then nItems goes down by one and the saved item is returned.

peekFront() returns the front item without changing anything, and isEmpty(), isFull() and size() simply consult nItems. Like StackX, the class trusts its user. It is up to the calling code to check isFull() before inserting and isEmpty() before removing.

The main() function gives the wraparound code a workout. It creates a queue with only five cells and then runs this sequence.

queue.cpp main() makes the queue wrap around
int main()
   {
   // five cells in all
   Queue theQueue(5);

   // four items arrive
   theQueue.insert(10);
   theQueue.insert(20);
   theQueue.insert(30);
   theQueue.insert(40);

   // three leave: 10, 20 and 30
   theQueue.remove();
   theQueue.remove();
   theQueue.remove();

   // four more arrive; rear runs off the end and wraps to cell 0
   theQueue.insert(50);
   theQueue.insert(60);
   theQueue.insert(70);
   theQueue.insert(80);

   // empty the queue, printing items in the order they leave
   while( !theQueue.isEmpty() )
      cout << theQueue.remove() << " ";
   cout << endl;
   return 0;
   }

The first four insertions fill cells 0 to 3. The three removals take out 10, 20 and 30, leaving only 40, with front at cell 3. Inserting 50 uses the last cell, cell 4. Then 60 arrives while rear is in the last cell, so rear wraps to cell 0, and 70 and 80 follow into cells 1 and 2. The queue is now completely full, with rear below front.

600701802403504rearfrontwraps around
The five-cell queue in main() just before it is emptied. Rear has wrapped around to cell 2, below front at cell 3. Removal starts at 40 and wraps from cell 4 back to cell 0.

The final loop removes and prints items until the queue is empty. front takes 40 from cell 3 and 50 from cell 4, wraps to cell 0, and continues with 60, 70 and 80. The items leave in exactly the order they arrived.

40 50 60 70 80

Efficiency of Queues

As with a stack, items can be inserted into and removed from a queue in O(1) time. insert() and remove() each touch a single cell and adjust a couple of integers, and neither depends on how many items are waiting. The wraparound tests add only a constant amount of work. The circular arrangement is what makes this possible. The shift-everything-down cure mentioned earlier would have made every removal an O(N) operation.

Deques

A deque, pronounced "deck," is a double-ended queue. You can insert items at either end and delete them from either end. The operations might be called insertLeft() and insertRight(), and removeLeft() and removeRight().

A deque can stand in for either of the other two structures. If you use only insertLeft() and removeLeft(), or only the two right-hand operations, it behaves like a stack. If you insert at one end and remove at the other, it behaves like a queue. That makes the deque more versatile than either one, although in practice it is used less often than stacks and queues.

Priority Queues

A priority queue is a more specialized structure. Like an ordinary queue it has a front and a rear, and items are removed from the front. The difference is that the items are kept in order by key value, so the item with the lowest key is always at the front, ready to be removed next. Each new item is inserted at whatever position keeps the order, which may be anywhere from the rear to the front. In the book's version a lower key means a higher priority. Some implementations work the other way around and keep the highest key at the front, but the idea is the same.

Lafore's picture is a person who sorts incoming mail by urgency. As each letter arrives, it goes into the pile at the position its importance deserves, with the most urgent letters on top and the ones that can wait at the bottom. Whenever there is time to deal with mail, the top letter is taken first, so the most urgent matter always gets handled first. Compare that with the plain mail stack of Hour 6, where the newest letter was always on top regardless of what it said.

Priority queues are workhorses in system software. In a preemptive multitasking operating system, the programs waiting to run sit in a priority queue, so the highest-priority program is the one that gets the processor next. Priority queues also appear in many graph algorithms and simulation programs.

The PriorityQ Workshop Applet

The PriorityQ applet implements a priority queue with an ordered array. The item with the smallest key sits at the top of the array, in the highest occupied cell, and larger keys lie toward cell 0. Keeping the smallest item at the top makes removal fast, since Rem simply takes the top item and nothing else has to move.

Insertion is the slow part. When you press Ins with a new key, the applet must find the right place for it. Starting at the top, it shifts every item smaller than the new one up one cell to make room, and then puts the new item into the opening. This is the same shifting you saw in the insertion sort in Hour 5. Peek shows the front item without removing it, and New creates a new, empty priority queue.

No wraparound is needed. The front of the queue is always at cell nItems−1 and the rear is always at cell 0, so the items never creep along the array the way they do in an ordinary queue.

queVect500401302203104567rearfront
A priority queue holding five items in descending order. The smallest key, 10, is at the front in the top occupied cell, where it will be removed next. The rear is always cell 0.

C++ Code for a Priority Queue

The program priorityQ.cpp defines a class named PriorityQ. It keeps its items in a vector named queVect in descending order, with the largest item in cell 0 and the smallest in the top occupied cell, nItems-1. There are no front and rear fields, because those positions never change.

priorityQ.cpp The PriorityQ class
class PriorityQ
   {
   private:
      int maxSize;
      // kept in descending order: largest at 0, smallest on top
      vector<double> queVect;
      int nItems;

   public:
      PriorityQ(int s) : maxSize(s), nItems(0)
         { queVect.resize(maxSize); }

      void insert(double item)
         {
         // begin at the front, where the smallest item sits
         int j = nItems - 1;
         // each item smaller than the newcomer moves up one cell
         while( j>=0 && queVect[j] < item )
            {
            queVect[j+1] = queVect[j];
            j--;
            }
         // the newcomer fills the opening just above cell j
         queVect[j+1] = item;
         nItems++;
         }

      // the smallest item always sits in the top occupied cell
      double remove()
         {
         nItems--;
         return queVect[nItems];
         }

      double peekMin()
         { return queVect[nItems-1]; }
      bool isEmpty()
         { return nItems == 0; }
      bool isFull()
         { return nItems == maxSize; }
   };

insert() starts with j at the front of the queue, the top occupied cell. As long as the item in cell j is smaller than the newcomer, that item is copied up one cell and j moves down. When the loop meets an item that is not smaller, or runs off the bottom of the array, the opening is at cell j+1, and the new item goes there. If the queue is empty, j starts at −1, the loop never runs, and the item lands in cell 0. The book's listing treats the empty queue as a separate case, but the loop covers it naturally.

queVect50030110234rearfront
Inserting 40 into a queue that holds 50, 30 and 10. Starting at the front, 10 and then 30 are smaller than 40, so each moves up one cell (arrows). 50 is larger, so the shifting stops.
queVect5004013021034rearfront
40 fills the opening at cell 1. The queue is still in descending order, with the smallest item, 10, at the front.

remove() could hardly be simpler. It reduces nItems by one and returns the item in cell nItems, which is the smallest one. The item stays in the cell, just as a popped item stays in a stack's vector, but it is no longer part of the queue. peekMin() returns the same item without removing it.

The main() function inserts 30, 50, 10, 40 and 20, in that scrambled order, and then removes and prints items until the queue is empty. Along the way the vector passes through the states [30], [50, 30], [50, 30, 10], [50, 40, 30, 10] and finally [50, 40, 30, 20, 10]. The items come out smallest first, whatever order they went in.

10 20 30 40 50

Efficiency of Priority Queues

In this implementation, insertion runs in O(N) time. On average a new item has to move past about half the items in the queue, and in the worst case past all of them. Deletion takes O(1) time, because the smallest item is always waiting in the top cell, and taking it needs no shifting at all.

The Part II structures so far, built on arrays
StructureInsertRemoveItem removed
StackO(1)O(1)The newest
Queue (circular array)O(1)O(1)The oldest
Priority queue (ordered array)O(N)O(1)The one with the lowest key

For a small priority queue, or one where items are inserted rarely, the ordered array is a perfectly good choice, and it is easy to write. When the queue holds many items and insertion speed matters, a different structure does better.

Summary

  • A queue is a first-in, first-out (FIFO) structure, so items leave in the order they arrived. A stack, by contrast, is LIFO.
  • Queues model waiting lines of all kinds, from moviegoers and airplanes to print jobs, network packets, keystrokes and simulated bank customers.
  • Items are inserted at the rear of a queue and removed from its front. Peek reads the front item without removing it.
  • In an array, removals leave empty cells behind front. A circular queue lets front and rear wrap around to cell 0 so that space gets reused.
  • The Queue class tracks front, rear and nItems. The item count makes the full and empty tests simple.
  • Queue insertion and removal both take O(1) time.
  • A deque allows insertion and deletion at both ends. Used one way it acts as a stack, and used another way it acts as a queue.
  • A priority queue keeps its items in order by key, so the item with the lowest key is always removed first. In the ordered-array version, insertion is O(N) and removal is O(1).

Q&A

Why would I ever use a deque?
When a problem needs to add or remove items at both ends, or when you want one general-purpose class that can serve as a stack in one part of a program and as a queue in another. Most problems need only one of the simpler behaviors, which is why deques are used less often than stacks and queues.
Why not shift the items down after each removal instead of wrapping around?
Because every removal would then move every item still in the queue, turning an O(1) operation into an O(N) one. Wraparound gets the same reuse of space for the price of an if statement.
What happens when two items in the priority queue have equal keys?
In PriorityQ, shifting stops at the first item that is not smaller than the newcomer, so a new item settles in front of any equal items and leaves before them. If items of equal priority should leave in arrival order, change the test to queVect[j] <= item, so the newcomer moves behind its equals.
Can a queue be built on something other than an array?
Yes. A linked list makes a natural queue, since items can be added at one end and removed from the other without any wraparound. Hour 9 returns to this when it looks at abstract data types.

Quiz

  1. What is the difference between FIFO and LIFO, and which structure uses each?

    Answer

    FIFO (first-in, first-out) removes items in the order they arrived; a queue works this way. LIFO (last-in, first-out) removes the newest item first; a stack works this way.

  2. Starting with an empty queue, you insert 1, 2 and 3, remove one item, insert 4, and then remove all the remaining items. In what order do the items come out?

    Answer

    1 comes out first, and then 2, 3 and 4.

  3. Why is a queue stored in an array usually made circular?

    Answer

    Removals leave empty cells at the bottom of the array. Letting front and rear wrap around to cell 0 reuses those cells, so rear doesn't get stuck at the top of the array while space is free, and no items ever have to be shifted.

  4. What can a deque do that a queue can't?

    Answer

    It can insert and remove items at both ends. That lets it act as a stack or as a queue, depending on which operations you use.

  5. What are the Big O efficiencies of insertion and removal in the ordered-array priority queue?

    Answer

    Insertion is O(N), because items must be shifted to make room. Removal is O(1), because the smallest item is always in the top occupied cell.

  6. In queue.cpp, why is 40 the first number printed?

    Answer

    Items 10, 20 and 30 were removed earlier, so 40 is the oldest item still in the queue when the final loop begins.

8

Part II · Abstract Data Types

Hour 8 Linked Lists

An array makes you pick its size up front, and it makes you shuffle items around whenever you insert or delete in the middle. A linked list ties its items together with pointers, so it grows and shrinks one item at a time and never has to move anything.

In this hour you'll learn

  • What a link is, and how pointers chain links into a list
  • How the LinkList Workshop applet inserts, finds and deletes links
  • How to write a simple linked list in C++ with insertFirst(), removeFirst() and displayList()
  • How to find a link with a given key and unhook it from the middle of the list
  • How linked lists compare with arrays in speed and in memory use

So far the array has done all the work in this book. It was a small database in Hour 2, an ordered array in Hour 3, and the storage inside the stacks and queues of Hours 6 and 7. Arrays have two built-in weaknesses. Inserting or deleting an item in the middle means shifting every item beyond it, which is slow. And you must fix an array's size when you create it, before you know how much data will turn up.

After the array, the linked list is probably the most widely used general-purpose storage structure, and it cures both problems. Items are added and removed by changing a pointer or two, so nothing ever shifts. The list is always exactly as long as the data it holds. You can use a linked list almost anywhere you would use an array. The main exception is a program that keeps asking for items by position number, as in "give me item 517". An array answers that in one step. A list can't.

This hour builds the basic machinery. In Hour 9 you'll use it to rebuild stacks and queues, and in Hour 10 you'll meet sorted lists and doubly linked lists.

In a linked list, every data item is packed inside an object called a link. The link holds the data plus one extra member, a pointer to the next link in the list. The links can be scattered anywhere in memory. What turns them into a list is that each one knows the address of the link that follows it.

The list as a whole is represented by a second, very small object of class LinkList. It holds a single pointer, pFirst, which points to the first link. The first link's pointer leads to the second link, the second link's pointer leads to the third, and so on down the chain. The last link's pointer holds NULL, the special value that means "points nowhere". That NULL is how code recognizes the end of the list.

pFirst22446688NULL
A linked list of four links. The LinkList object knows only pFirst. Each link points to the next one, and the last link points to NULL.

A Class That Refers to Itself

Here is the declaration of a link that carries two pieces of data, an integer and a floating-point number.

linkList.cpp The data members of a link
class Link
   {
   public:
      // The two data items this link carries.
      int iData;
      double dData;
      // Where the following link lives, or NULL if this is the last one.
      Link* pNext;
   };

The interesting member is pNext. Its type is Link*, a pointer to an object of the very class being declared. A class like this is called self-referential. That sounds like a riddle, but the compiler is perfectly happy with it, because pNext is only an address. The compiler knows how much room an address takes, so it can lay out a Link without knowing anything else about it. What a class can't do is contain a complete object of its own type, since that inner object would contain another one, and that one another, forever.

A link can hold any data at all: numbers, strings, a complete object such as the Person records of Hour 3, or a pointer to such an object. The list code never looks at the data except to compare keys. The part it cares about is pNext. The book keeps its links small, with one or two numbers, so the pointer logic stays easy to follow.

Relationships Instead of Positions

In an array, every item sits at a numbered position. If you want the item at index 7, the index takes you there in a single step, however big the array is. A linked list has no position numbers at all. An item belongs to the list only because the link in front of it points to it. So the one and only way to reach an item is to start at pFirst and follow the pointers, link by link, until you arrive.

Lafore compares this to tracking someone down through other people. Say you're looking for Bob. You ask Harry, who has no idea, though he thinks Jane might know. Jane remembers seeing Bob go off with Sally. Sally tells you she gave Bob a ride to Peter's office, and that is where you finally find him. You couldn't have gone straight to Peter's office, because you didn't know about it until Sally told you. Each person knew only the next step.

asknextnextnextYouWhere is Bob?HarryAsk JaneJaneHe left with SallySallyI drove him to Peter'sPeter's officeBob is here
Finding Bob through a chain of people. Like the links of a list, each person knows only where to look next, so you can't skip ahead.

A linked list works the same way. To reach the fourth link, you must pass through the first, second and third, because only the third link knows where the fourth one is. Keep this picture in mind. It explains both the strengths and the weaknesses of lists that you'll see at the end of this hour.

The book's LinkList Workshop applet draws each link as a small rectangle showing its key, with an arrow from each rectangle to the next. A long list won't fit across the screen, so the links wrap onto several rows, like words on a page. Four buttons drive it.

  • Ins asks for a key, makes a new link holding it, and puts that link at the beginning of the list. The new link's arrow points to the link that used to be first. In an unsorted list, inserting at the front is the fastest insertion there is, because there's nothing to search for and nothing to move.
  • Find asks for a key, then moves a red arrow from link to link, starting at the first one, until it reaches the link with that key. You can watch the cost of a list search directly. The farther along the key sits, the longer the arrow travels.
  • Del asks for a key, finds its link the same step-by-step way, and then removes it. On screen, the arrow leaving the link before the deleted one swings around to point at the link after it, so the chain simply skips over the deleted link.
  • New starts a fresh list. It asks how many links you want and whether the list should be sorted or unsorted. Sorted lists are the topic of Hour 10, so stay with unsorted lists for now.

Our re-creation below works the same way. It also labels the two pointers the C++ code uses while it walks the list, pCurrent and pPrevious. You'll meet both of them in the code later this hour.

Implementing a Simple Linked List

The first program, linkList.cpp, supports only three operations: insert a link at the front of the list, remove the link at the front, and display the whole list. That sounds modest, but those three are exactly what a stack needs, as you'll see in Hour 9. The program has two classes, Link and LinkList, plus a main() that tries them out.

The complete Link class adds a constructor and a function that displays the link's data.

linkList.cpp The Link class
#include <iostream>
#include <cstddef>
using namespace std;

class Link
   {
   public:
      int iData;
      double dData;
      Link* pNext;

      // A new link receives its data and isn't attached to anything yet.
      Link(int id, double dd) : iData(id), dData(dd), pNext(NULL)
         { }

      // Print the data in the form {22, 2.99}.
      void displayLink()
         {
         cout << "{" << iData << ", " << dData << "} ";
         }
   };

The constructor copies its two arguments into the data members and sets pNext to NULL. A brand-new link isn't part of any list yet, so it shouldn't point anywhere. The list code will set pNext when it hooks the link into the chain. displayLink() prints the two data items inside braces, such as {22, 2.99}. The members are public so the list class can reach them directly, which keeps the code short.

The LinkList class needs only one data member, the pointer to the first link. Here is the class with everything except displayList(), which comes a little later.

linkList.cpp The LinkList class
class LinkList
   {
   private:
      // Address of the first link, or NULL when the list is empty.
      Link* pFirst;

   public:
      LinkList() : pFirst(NULL)
         { }

      bool isEmpty()
         { return pFirst == NULL; }

      Link* getFirst()
         { return pFirst; }

      // Make a new link and put it at the front of the list.
      void insertFirst(int id, double dd)
         {
         Link* pNewLink = new Link(id, dd);
         // First, the new link points at the current first link.
         pNewLink->pNext = pFirst;
         // Then the list starts at the new link.
         pFirst = pNewLink;
         }

      // Unhook the first link and give its memory back.
      // Call this only when the list isn't empty.
      void removeFirst()
         {
         Link* pTemp = pFirst;
         pFirst = pFirst->pNext;
         delete pTemp;
         }

      void displayList();
   };

The constructor sets pFirst to NULL, so every new list starts out empty, and isEmpty() simply checks for that value. getFirst() hands out the address of the first link. As you'll see, main() uses it to look at a link before removing it.

The insertFirst() Member Function

insertFirst() is the heart of the class. It creates a new link with new, which returns the address of the new object, and keeps that address in pNewLink. Then it makes two pointer assignments.

  1. pNewLink->pNext = pFirst; The new link's pNext now points at whatever link used to be first. If the list was empty, pFirst was NULL, so the new link's pNext becomes NULL. That is correct, because in a one-link list the new link is also the last.
  2. pFirst = pNewLink; The list now begins at the new link.
pFirst664422NULL88pNewLink
Before: the new link (88) is created off to the side. The dashed arrows are the two assignments to come: 88 will point at 66, and pFirst will point at 88.
pFirst88664422NULL
After: the list starts at 88. None of the older links moved or changed.

The order of the two assignments matters. Suppose you did the second one first. pFirst would hold the new link's address, and nothing would remember where the old first link was. The other assignment would then make the new link point to itself, and the rest of the list would be adrift in memory with no pointer leading to it.

However long the list is, insertFirst() does the same two assignments. Its running time doesn't depend on N at all, so it's O(1).

The removeFirst() Member Function

removeFirst() undoes what insertFirst() did. The first link's pNext holds the address of the second link, so the statement pFirst = pFirst->pNext; reroutes pFirst past the first link to the second. The old first link is now outside the chain.

In Java, an object that nothing points to is eventually cleaned up by the garbage collector. C++ has no garbage collector. Memory you get with new stays allocated until you hand it back with delete, so removeFirst() has to delete the old link itself. If it didn't, every removal would leave a dead link lying in memory, a problem called a memory leak. That's why the function starts by saving the old link's address in pTemp. Once pFirst moves on, pTemp is the only remaining record of where that link lives, and delete needs the address.

The displayList() Member Function

To display the list, the program has to visit every link in turn, starting at the first.

linkList.cpp Walking the list to display it
void LinkList::displayList()
   {
   cout << "List (first-->last): ";
   // Begin at the head of the chain.
   Link* pCurrent = pFirst;
   // Stop when we step past the last link.
   while(pCurrent != NULL)
      {
      pCurrent->displayLink();
      // Move to the neighbor this link points to.
      pCurrent = pCurrent->pNext;
      }
   cout << endl;
   }

The function keeps a pointer called pCurrent that starts at pFirst. Each time around the loop, it displays the link that pCurrent points to and then moves on with pCurrent = pCurrent->pNext;. That statement is the basic step of all list code. It replaces the address in pCurrent with the address stored inside the current link, which is the address of the next link. When pCurrent steps past the last link it picks up that link's NULL, and the loop ends. For an empty list, pCurrent is NULL from the start, so the loop body never runs.

The main() Function

main() builds a list of four links, displays it, and then takes the links off the front one at a time until the list is empty.

linkList.cpp Trying out the list
int main()
   {
   LinkList theList;

   // Each call puts its link in front of the others.
   theList.insertFirst(22, 2.99);
   theList.insertFirst(44, 4.99);
   theList.insertFirst(66, 6.99);
   theList.insertFirst(88, 8.99);
   theList.displayList();

   while( !theList.isEmpty() )
      {
      // Show the first link while it still exists.
      Link* pTemp = theList.getFirst();
      cout << "Removed ";
      pTemp->displayLink();
      cout << endl;
      // Now it can go.
      theList.removeFirst();
      }
   theList.displayList();
   return 0;
   }
List (first-->last): {88, 8.99} {66, 6.99} {44, 4.99} {22, 2.99}
Removed {88, 8.99}
Removed {66, 6.99}
Removed {44, 4.99}
Removed {22, 2.99}
List (first-->last):

The list comes out in the reverse of the order the links went in. Each insertFirst() puts its link in front of all the others, so the last one inserted (88) ends up first. The loop then removes links from the front until isEmpty() reports true, and the final display shows an empty list. A list used this way behaves like a stack, where the last item in is the first one out. Hour 9 builds on exactly that.

The second program, linkList2.cpp, keeps everything from the first one and adds two member functions. find() searches the list for a link with a given key. remove() searches for a link with a given key and takes it out of the list, wherever it happens to be. Both treat iData as the key. Like displayList(), they are declared inside the class and defined after it.

The find() Member Function

linkList2.cpp Searching for a key
// Return the link holding this key, or NULL if there isn't one.
Link* LinkList::find(int key)
   {
   Link* pCurrent = pFirst;
   // Keep going while there is a link and it isn't the one we want.
   while(pCurrent != NULL && pCurrent->iData != key)
      pCurrent = pCurrent->pNext;
   return pCurrent;
   }

find() is a traversal that can stop early. pCurrent starts at pFirst and steps along the chain, exactly as in displayList(). The loop keeps going as long as there is a current link and its key isn't the one wanted. It stops for one of two reasons: pCurrent points to a matching link, or pCurrent has become NULL because the search ran off the end of the list. Either way, returning pCurrent gives the caller the right answer, the address of the link or NULL for "not found".

The order of the two tests matters. C++ evaluates && from left to right and stops as soon as the result is known. When pCurrent is NULL, the left test fails and the right test, which reads pCurrent->iData, never runs. That is what you want, because following a NULL pointer crashes the program. Lafore's version checks for the end of the list in a slightly different way. Inside the loop, it looks at whether the current link's pNext is NULL before stepping forward, and returns NULL if it is. Both versions visit the same links. Ours also copes with an empty list, where pFirst itself is NULL.

Once find() has handed you a link, inserting a new link right after it takes just two assignments, the same pattern as insertFirst(). Point the new link's pNext at the found link's successor, then point the found link's pNext at the new link.

The remove() Member Function

Removing a link from the middle of a list is trickier than removing the first one. The pointer that has to change belongs to the link before the one being deleted. In a singly linked list you can only move forward, so once pCurrent has reached the target there's no way to step back. The fix is to carry a second pointer, pPrevious, that trails one link behind pCurrent all the way down the list.

linkList2.cpp Removing the link with a given key
// Unhook and delete the link holding this key.
// Return false if no link holds it.
bool LinkList::remove(int key)
   {
   Link* pPrevious = NULL;
   Link* pCurrent = pFirst;
   // Walk the list with pPrevious one step behind pCurrent.
   while(pCurrent != NULL && pCurrent->iData != key)
      {
      pPrevious = pCurrent;
      pCurrent = pCurrent->pNext;
      }
   // We fell off the end, so the key isn't in the list.
   if(pCurrent == NULL)
      return false;
   // Close the gap around pCurrent.
   if(pCurrent == pFirst)
      pFirst = pFirst->pNext;
   else
      pPrevious->pNext = pCurrent->pNext;
   delete pCurrent;
   return true;
   }

Each time the loop advances, pPrevious takes over pCurrent's old value, and then pCurrent moves on to the next link. When the loop ends, one of three things is true.

  • pCurrent is NULL. No link holds the key, so remove() returns false and changes nothing.
  • pCurrent is the first link. There is no previous link to fix, so pFirst moves to the second link, just as in removeFirst().
  • pCurrent is somewhere further along. The assignment pPrevious->pNext = pCurrent->pNext; makes the previous link point to the link after pCurrent, so the chain now skips over it.

In the last two cases the link is out of the chain, and delete pCurrent returns its memory. Notice that the list never moves anything. However long the list is, removing a link changes exactly one pointer once the link has been found.

pFirst88664422NULLpPreviouspCurrent
Removing the link with key 66. pPrevious trails one link behind pCurrent. One assignment makes the 88 link point past 66 to 44, and delete then frees the 66 link.

The main() Function of linkList2.cpp

This main() builds the same four-link list as before, displays it, and then tries the two new functions.

linkList2.cpp The new part of main()
// Look for the link whose key is 44.
Link* pFound = theList.find(44);
if(pFound != NULL)
   cout << "Found link with key " << pFound->iData << endl;
else
   cout << "Can't find link" << endl;

// Take out the link whose key is 66.
if( theList.remove(66) )
   cout << "Removed link with key 66" << endl;
else
   cout << "Can't remove link" << endl;

theList.displayList();
List (first-->last): {88, 8.99} {66, 6.99} {44, 4.99} {22, 2.99}
Found link with key 44
Removed link with key 66
List (first-->last): {88, 8.99} {44, 4.99} {22, 2.99}

The search for 44 compares three keys (88, 66 and 44) before it succeeds. The removal of 66 compares two keys and then changes a single pointer, the one inside the 88 link.

The Efficiency of Linked Lists

Speed

Insertion and deletion at the beginning of a linked list are about as fast as operations get. insertFirst() and removeFirst() each change a pointer or two, and the number of steps doesn't depend on how many links the list holds. Both are O(1).

Finding a particular link, deleting it, or inserting a new link next to it is another matter, because you have to find the link first, and that means walking from pFirst. On average the target is halfway down the list, so a search takes about N/2 comparisons. For a list of 10,000 links, that's around 5,000. Big O drops the constant, so these operations are O(N), the same as searching an unsorted array.

Even so, the list beats the array at deletions and at insertions in the middle, because once the spot is found, nothing has to move. To delete an item halfway through a 10,000-item array, you compare about 5,000 keys and then shift about 5,000 items down one cell to close the gap. In a list you compare about 5,000 keys and change one pointer. When the items are large objects, copying one takes much longer than comparing a key, and the list's advantage grows.

1102213324435545
Deleting 33 from an array. Every item above the hole (44 and 55) must shift down one cell.
pFirst1122334455NULL
Deleting 33 from a list. One pointer changes, and every other link stays where it is.
Unsorted array and linked list compared
OperationUnsorted arrayLinked list
Insert a new itemO(1), at the endO(1), at the front
Remove the first itemO(N), the rest shift downO(1)
Find an item by keyO(N), about N/2 comparisonsO(N), about N/2 comparisons
Delete an item by keyO(N), N/2 comparisons plus N/2 movesO(N), N/2 comparisons and no moves
Reach the item at position iO(1), use the indexO(N), walk i links
SizeFixed when createdGrows and shrinks one link at a time

Memory

A linked list uses exactly as much memory as it needs. Each insertion allocates one link and each removal frees one, and the list can keep growing until the computer runs out of memory. You never have to guess its size in advance.

An array's size is fixed when you create it, which leads to one of two problems. If the array is too small, it overflows when more data arrives than you planned for. If it's too large, the unused cells waste memory. A vector (Hour 2) eases the problem because it can grow, but growing is expensive. The vector has to grab a new, larger block of memory, usually about twice as big, and copy every item into it. Right after it grows, much of the new block sits unused.

The list pays for its flexibility in two ways. First, there is no random access. You can't jump to the 500th link; you have to walk there. That also rules out algorithms that leap into the middle of the data, such as the binary search of Hour 3. Second, every link carries a pointer as well as its data. When the data is a single small number, the pointer can double the memory each item needs.

So linked lists shine when the amount of data is hard to predict and insertions and deletions are frequent. Arrays win when you know roughly how many items you'll have and you need to reach them by position.

Summary

  • A linked list is a chain of link objects. Each link holds a data item and a pointer, pNext, to the next link.
  • The LinkList object holds only pFirst, the address of the first link. The last link's pNext is NULL, and in an empty list pFirst itself is NULL.
  • Class Link is self-referential, because it contains a pointer to an object of its own type.
  • Items in a list have relationships instead of positions. The only way to reach an item is to follow the chain from the start.
  • insertFirst() points the new link at the old first link, then points pFirst at the new link. The order matters, and the whole job is O(1).
  • removeFirst() reroutes pFirst to the second link and must delete the old link, because C++ has no garbage collector.
  • To traverse a list, start a pointer at pFirst and repeat pCurrent = pCurrent->pNext until it becomes NULL.
  • Finding or removing a link with a given key takes about N/2 comparisons, O(N). Removal needs pPrevious so it can fix the link before the target.
  • Compared with arrays, lists insert and delete without moving anything and never run out of room early. They give up fast access by index and spend extra memory on a pointer per link.

Q&A

When should I choose a linked list over an array?
Choose a list when you can't predict how much data you'll have, and when insertions and deletions are common, especially at the front or in the middle. Choose an array when you know roughly how many items there will be and you need to reach them by index, for example to run a binary search.
Why do I have to delete links myself?
Every link was created with new, and in C++ that memory stays allocated until someone calls delete. If you lose the last pointer to a link without deleting it, the memory leaks. For the same reason, a complete list class would normally have a destructor that walks the list and deletes every remaining link when the list itself goes away.
Can a link hold more than a couple of numbers?
Yes. A link can hold a string, a whole object such as a Person record, or a pointer to an object stored elsewhere. With large objects the pointer approach is common, because the link stays small and the object never has to be copied.
Is following pointers slow?
Each step is fast, just one memory read. The trouble is the number of steps. Reaching the 500th link takes 500 of them, while an array index gets there in one. That is the price of storing relationships instead of positions.

Quiz

  1. What does pFirst contain when a list is empty?

    Answer

    NULL. The constructor puts it there, and isEmpty() tests for it.

  2. insertFirst() performs pNewLink->pNext = pFirst; and then pFirst = pNewLink;. What goes wrong if you swap them?

    Answer

    The address of the old first link is overwritten before it has been copied into the new link. The new link ends up pointing to itself, and the rest of the list is lost (and its memory leaks).

  3. Why does remove() need a pPrevious pointer as well as pCurrent?

    Answer

    Deleting a link means changing the pNext of the link before it. A singly linked list can't move backward, so the code keeps pPrevious one step behind pCurrent to remember that earlier link.

  4. What are the Big O times of insertFirst() and of find()?

    Answer

    insertFirst() is O(1), a fixed two assignments. find() is O(N), about N/2 comparisons on average.

  5. Name one advantage and one disadvantage of a linked list compared with an array.

    Answer

    Advantages: insertion and deletion move no items, and the list grows and shrinks as needed. Disadvantages: there's no fast access by index (so no binary search), and each link needs extra memory for its pointer.

  6. Starting with an empty list, you call insertFirst() with the keys 10, 20 and 30, in that order, and then call removeFirst() once. Which keys does displayList() show?

    Answer

    20 then 10. The list was 30, 20, 10, and removeFirst() took away 30.

9

Part II · Abstract Data Types

Hour 9 Abstract Data Types

The same stack or queue can be built on an array or on a linked list, and the program that uses it can't tell which. This hour does exactly that, and then uses the result to explain abstract data types, which describe a data structure by what it does and keep the details of how it does it out of sight.

In this hour you'll learn

  • How to build a stack on a linked list
  • How a double-ended list keeps a pointer to its last link, and what that buys you
  • How to build a queue on a double-ended list
  • What data types, abstraction and abstract data types are
  • How ADTs help you design programs, and why "abstract" is a relative term

An abstract data type, or ADT, is a way of looking at a data structure that concentrates on what it does and ignores how it does it. You have already used two of them. The stack of Hour 6 offered push, pop and peek, and the queue of Hour 7 offered insert, remove and peek. Behind those operations, both classes kept their items in arrays. Nothing about the operations themselves requires an array, though.

The first half of this hour rebuilds the stack and the queue on top of the linked lists from Hour 8. The programs that use them hardly change, and they can't tell the difference. Once you've watched that happen, the idea of an ADT stops being vague, and the second half of the hour looks at what ADTs are and how they help you design software.

A Stack Implemented By a Linked List

Look back at insertFirst() and removeFirst() from Hour 8. One puts an item at the front of the list, and the other takes the front item off. If you call the front of the list the top of a stack, those two functions are push and pop. The linkStack.cpp program makes that connection official.

It uses a slimmed-down version of the Hour 8 list. Each link carries a single double called dData. And removeFirst() now returns the value it removes, because a stack's pop() has to hand the item back to the caller.

linkStack.cpp The list that stores the stack
class Link
   {
   public:
      double dData;
      Link* pNext;
      Link(double dd) : dData(dd), pNext(NULL)
         { }
      void displayLink()
         { cout << dData << " "; }
   };

class LinkList
   {
   private:
      Link* pFirst;
   public:
      LinkList() : pFirst(NULL)
         { }
      bool isEmpty()
         { return pFirst == NULL; }
      void insertFirst(double dd)
         {
         Link* pNewLink = new Link(dd);
         pNewLink->pNext = pFirst;
         pFirst = pNewLink;
         }
      // Unhook the first link, free it, and return its data.
      double removeFirst()
         {
         Link* pTemp = pFirst;
         double key = pTemp->dData;
         pFirst = pTemp->pNext;
         delete pTemp;
         return key;
         }
      void displayList()
         {
         // The Hour 8 traversal, written as a for loop.
         for(Link* pCurrent = pFirst; pCurrent != NULL; pCurrent = pCurrent->pNext)
            pCurrent->displayLink();
         cout << endl;
         }
   };

The one new detail is in removeFirst(). It copies the link's data into key before it deletes the link, since after delete the data is gone. The traversal in displayList() is the same walk as in Hour 8, squeezed into a for loop: start at pFirst, stop at NULL, and follow pNext at each step.

Now the stack itself. Class LinkStack contains a LinkList object as its only data member, and every one of its member functions is a one-liner that passes the job to the list.

linkStack.cpp The LinkStack class and main()
class LinkStack
   {
   private:
      // The items live in this list. Its front is the top of the stack.
      LinkList theList;
   public:
      // Put an item on top.
      void push(double j)
         { theList.insertFirst(j); }
      // Take the top item off and return it.
      double pop()
         { return theList.removeFirst(); }
      bool isEmpty()
         { return theList.isEmpty(); }
      void displayStack()
         {
         cout << "Stack (top-->bottom): ";
         theList.displayList();
         }
   };

int main()
   {
   LinkStack theStack;
   theStack.push(20);
   theStack.push(40);
   theStack.displayStack();

   theStack.push(60);
   theStack.push(80);
   theStack.displayStack();

   theStack.pop();
   theStack.pop();
   theStack.displayStack();
   return 0;
   }
Stack (top-->bottom): 40 20
Stack (top-->bottom): 80 60 40 20
Stack (top-->bottom): 40 20

Each push puts a new link at the front of the list, so the most recent item is always first, which is to say on top. The two pops remove 80 and then 60, the last two items pushed, leaving 40 and 20. LinkStack doesn't even need a constructor of its own. When a LinkStack is created, C++ automatically runs the constructor of its theList member, which sets pFirst to NULL.

usespasses each call onmain()calls push() and pop()LinkStackpush() · pop() · isEmpty()LinkListinsertFirst() · removeFirst()
Layers in linkStack.cpp. main() talks only to LinkStack, and LinkStack hands every request to its LinkList.

Now compare this main() with the one in Hour 6. The push and pop calls are exactly the same. The only change is the line that creates the stack. StackX needed a size, as in StackX theStack(10);, while LinkStack theStack; needs none, because a linked stack never fills up until the computer runs out of memory. For the same reason, LinkStack has no isFull(). Everything else about the storage is invisible to the user of the class. That invisibility is the whole point of this hour.

Both versions push and pop in O(1) time. In the array version, each operation moves top and reads or writes one cell. In the list version, each one changes a pointer or two and calls new or delete.

Double-Ended Lists

A double-ended list is an ordinary linked list with one addition. Besides pFirst, the list object keeps a second pointer, pLast, that points to the last link.

pFirst664422113355NULLpLast
A double-ended list. pFirst points to the first link as usual, and pLast points straight to the last one.

With pLast available, you can attach a new link at the end of the list directly. In an ordinary list you'd first have to walk all N links to find the end, an O(N) job. With pLast it takes a couple of assignments, O(1). Inserting at the end is exactly what a queue needs, as you'll see in the next section.

The firstLastList.cpp program implements a double-ended list. Its Link class is the one-number link from linkStack.cpp.

firstLastList.cpp A list with pointers to both ends
class FirstLastList
   {
   private:
      Link* pFirst;
      // The extra pointer: the last link in the list.
      Link* pLast;
   public:
      FirstLastList() : pFirst(NULL), pLast(NULL)
         { }
      bool isEmpty()
         { return pFirst == NULL; }

      void insertFirst(double dd)
         {
         Link* pNewLink = new Link(dd);
         // In an empty list, the new link is the last one too.
         if( isEmpty() )
            pLast = pNewLink;
         pNewLink->pNext = pFirst;
         pFirst = pNewLink;
         }

      void insertLast(double dd)
         {
         Link* pNewLink = new Link(dd);
         // Attach the new link after the old last link,
         // or make it the first link if the list is empty.
         if( isEmpty() )
            pFirst = pNewLink;
         else
            pLast->pNext = pNewLink;
         pLast = pNewLink;
         }

      double removeFirst()
         {
         Link* pTemp = pFirst;
         double key = pTemp->dData;
         // Removing the only link empties both ends.
         if(pFirst->pNext == NULL)
            pLast = NULL;
         pFirst = pFirst->pNext;
         delete pTemp;
         return key;
         }

      void displayList();
   };

Each function has to take care of a special case, the moment when pFirst and pLast must change together.

  • insertFirst() works as before, except that when the list is empty, the new link is also the last link, so pLast must point to it too. The emptiness test has to come first, while pFirst still tells the truth about the old list.
  • insertLast() makes the old last link's pNext point to the new link and then moves pLast to the new link. When the list is empty there's no old last link to attach to, so the new link becomes the first link instead. The new link's own pNext is already NULL, set by the Link constructor, which correctly marks it as the end.
  • removeFirst() works as before, except when it removes the only link. Then the list becomes empty, and pLast must become NULL as well. Otherwise it would go on pointing at a link that has just been deleted.
pFirst6644221133NULLpLast55NULLpNewLink
insertLast(55). The old last link (33) gets a pointer to the new link, and then pLast moves to it. No walk down the list is needed.

The main() in firstLastList.cpp inserts 22, 44 and 66 at the front, inserts 11, 33 and 55 at the end, and displays the list. Then it removes the first two items and displays the list again.

List (first-->last): 66 44 22 11 33 55
List (first-->last): 22 11 33 55

The items inserted at the front pile up in reverse order (66 44 22), while the items inserted at the end line up in the order they arrived (11 33 55). The two calls to removeFirst() take away 66 and 44.

A double-ended list does nothing for removing the last link, though. To remove it you would have to make the next-to-last link's pNext NULL and move pLast back to that link. The only way to find the next-to-last link is to walk from pFirst, which is O(N), because the pointers lead only forward. The doubly linked list of Hour 10 solves this by giving every link a backward pointer as well.

Implementing a Queue Using a Linked List

A queue inserts items at the rear and removes them from the front. A double-ended list does both of those in O(1) time: insertLast() adds a link at the end, and removeFirst() takes one off the front. So linkQueue.cpp wraps a FirstLastList inside a LinkQueue class, in the same way LinkStack wrapped a LinkList. Each program in this hour is a self-contained file with its own copy of the list class. In this program's copy, displayList() prints only the items, and displayQueue() puts a label in front of them.

linkQueue.cpp The LinkQueue class
class LinkQueue
   {
   private:
      // The front of the queue is the first link; the rear is the last.
      FirstLastList theList;
   public:
      bool isEmpty()
         { return theList.isEmpty(); }
      // New items join at the rear.
      void insert(double j)
         { theList.insertLast(j); }
      // Items leave from the front.
      double remove()
         { return theList.removeFirst(); }
      void displayQueue()
         {
         cout << "Queue (front-->rear): ";
         theList.displayList();
         }
   };

The main() for this program inserts 20 and 40 and displays the queue, inserts 60 and 80 and displays it again, and then removes two items and displays what's left.

Queue (front-->rear): 20 40
Queue (front-->rear): 20 40 60 80
Queue (front-->rear): 60 80

Items leave in the order they arrived. The first two in, 20 and 40, are the first two out. Compare this with the array-based Queue class of Hour 7. That class needed a fixed size, a count of items, and wraparound code to move front and rear back to the start of the array. None of that is needed here. The front of the queue is simply pFirst, the rear is pLast, and the list grows and shrinks as items come and go.

pFirst20406080NULLfrontrearpLast
The linked queue after four insertions. pFirst marks the front, where remove() works, and pLast marks the rear, where insert() works.

The applet below makes the "same interface, different insides" idea visible. One set of buttons drives two implementations at once: an array version like StackX and Queue on one side, and a linked-list version like LinkStack and LinkQueue on the other. Every push or insert goes to both, so you can compare what each one does with the same request.

Data Types and Abstraction

With two stacks and two queues in hand, it's time to look at the idea behind them. The phrase "abstract data type" has two halves, and each is worth taking apart.

Data Types

The phrase data type is used first for built-in types such as int and double. When you declare an int, you get more than a place to keep a number. You get a set of values, the whole numbers within a certain range (on a typical 32-bit system, from about −2.1 billion to about +2.1 billion). You also get a set of operations that are allowed on those values, such as +, −, *, / and comparisons. The operations are part of the type. You can use % to find the remainder when one int is divided by another, but C++ won't let you apply % to a double.

Classes let you create data types of your own. A class says what data its objects hold and which member functions may operate on that data, just as the language defines the values and operators of an int. A stack class is a new data type whose operations are push, pop and peek. A queue class is a data type with insert, remove and peek. Once such a class is written, you can declare a stack as easily as you declare an int.

A data type is values plus operations
TypeValuesOperations
intWhole numbers in a fixed range+ − * / %, comparisons
doubleNumbers with fractional parts+ − * /, comparisons (no %)
StackA collection of items, only the top one reachablepush, pop, peek, isEmpty
QueueA collection of items in arrival orderinsert, remove, peek, isEmpty
ListA collection of items in linear orderinsert, delete, read an item

Abstraction

To abstract something is to consider it apart from its detailed specification or implementation. An abstraction keeps the essential idea and leaves out the machinery. When you drive a car, you think of it as something that speeds up when you press the pedal. You don't think about fuel pressure or valve timing, even though they are what make the car move.

In object-oriented programming, then, an abstract data type is a class considered without regard to its implementation. It is a description of the data the class holds, plus a list of the operations that can be carried out on that data and instructions on how to use them. Users of the class know what each operation does. They don't know how it does it, and they don't need to.

The stack is a good example. For the main() in Hour 6 and the main() earlier in this hour, a stack was simply "something you push items onto and pop them off, where the last item pushed is the first one popped". That description is the ADT. StackX, with its vector, and LinkStack, with its linked list, are two different implementations of the same ADT.

The Interface

The set of operations an ADT offers to the outside world is called its interface. In a C++ class, the interface is the list of public member functions, along with any rules for using them, such as "check isEmpty() before you call pop()". Everything private belongs to the implementation: the data members, plus any helper functions the class uses internally. A user who sticks to the interface never depends on the private parts, which is why those parts can be replaced.

ADT Lists

The word "list" is used for an ADT too. A list, sometimes called a linear list, is a group of items arranged in a linear order, one after another. Typical operations are insert an item, delete an item, and read an item (for example, the item at a given place in the order). Don't confuse this list ADT with the linked list. A list ADT can be implemented with an array or with a linked list, in the same way that a stack can.

Using ADTs as a Design Tool

The ADT idea is a practical help when you design a program. Before you decide how to store your data, decide what you need to do with it. Perhaps the program needs the most recently added item, or items in the order they arrived, or fast search by key. Those needs define the operations, and the operations define the ADT: a stack, a queue, or something else.

Only after the operations are settled do you choose an implementation, and you choose the one that makes the most frequent operations fast. Then you write the rest of the program against the interface alone. Two good things follow. The design stays clean, because the code that uses the data structure contains no storage details. And you can change the implementation later without touching that code. If an array-based stack turns out to overflow in real use, you can drop in the linked-list version, and the calling code keeps working, as you saw with LinkStack.

callsone choiceor anotherYour programADT interfacepush · pop · peekarray(StackX)linked list(LinkStack)
Designing with an ADT. The program depends only on the interface. Either implementation can sit underneath, and you can swap one for the other later.

For stacks and queues, Big O doesn't settle the choice, because both versions do their main operations in O(1) time. The deciding question is usually how much data to expect.

  • Use an array when you know in advance roughly the largest number of items you'll need to hold, and you want speed. The array version is quicker in practice, because it doesn't call new and delete for every item, and it spends no memory on pointers.
  • Use a linked list when you can't predict how many items there will be. It never fills up, and it uses memory only for the items it actually holds.
Two ways to implement a stack or a queue
Array (StackX, Queue)Linked list (LinkStack, LinkQueue)
Main operationsO(1)O(1)
SizeFixed when created, so check isFull()Grows as needed
Memory per itemJust the itemThe item plus a pointer
Extra work per operationNoneA new or a delete

Abstract Is a Relative Term

Abstraction comes in degrees. Take the priority queue of Hour 7. As an ADT, it is defined by its operations: insert an item, and remove the item with the smallest key. In Hour 7 you implemented it with an ordered array. It could also be implemented with a heap, a structure this book doesn't cover. From the priority queue's point of view, the ordered array is just the implementation. But the ordered array is itself an abstraction, built on something lower down, the computer's memory, which is a long row of numbered storage locations.

implemented byor bybuilt onbuilt onPriority queueinsert · remove smallestOrdered arrayshift to insertHeapanother choiceMemorynumbered locations
Levels of abstraction. The priority queue rests on an ordered array (or a heap), and the array in turn rests on raw memory. Each level hides the one below it.

So stacks, queues and priority queues are more abstract than arrays and linked lists, which in turn are more abstract than raw memory. Which of these you call "the ADT" and which "the implementation" depends on where you're standing. To someone writing a program that schedules jobs, the priority queue is the ADT and the array is a hidden detail. To the person writing the PriorityQ class, the array is a tool with its own interface of cells and indices. Even the linked list can be seen as an ADT, with an interface of insertFirst(), find() and remove() that hides how its pointers are arranged.

Summary

  • A stack can be built on a linked list. LinkStack's push() calls insertFirst() and its pop() calls removeFirst(), so the front of the list is the top of the stack.
  • Code that uses LinkStack looks the same as code that uses the array-based StackX, except that a linked stack needs no size and never fills up.
  • A double-ended list keeps a pLast pointer as well as pFirst, so inserting at the end is O(1). Removing the last link is still slow, because the next-to-last link can't be reached without a walk.
  • A queue can be built on a double-ended list, inserting with insertLast() and removing with removeFirst(). No wraparound or item count is needed.
  • A data type is a set of values plus the operations allowed on them. Classes let programmers create new data types.
  • An abstract data type is a class considered without regard to its implementation: its data and its operations. The operations form its interface.
  • A list (linear list) is an ADT too, and it can be implemented with an array or a linked list.
  • When designing, settle the operations first, then pick the implementation that makes the frequent ones fast. Use arrays when the maximum size is known and speed matters, and linked lists when the amount of data is unpredictable.
  • Abstraction is relative. A priority queue is built on an array, which is built on memory, and what counts as the ADT depends on your point of view.

Q&A

If the array versions are faster, why build stacks and queues on lists at all?
Because you often can't know how many items a stack or queue will have to hold. An array sized too small overflows, and one sized too large wastes memory. The list version always has room for one more item and uses memory only for what it holds. When the size is known, the array is the better choice.
Is a linked list a data structure or an ADT?
It depends on your point of view. Seen from LinkStack, the linked list is the implementation, a data structure. Seen from its own users, it is described by operations such as insertFirst() and find(), which makes it an ADT with its own interface.
Could I build a queue on an ordinary, single-ended list?
Yes, but one of the two operations would be slow. Either you insert at the end, which means walking to the end first, or you remove from the end, which means walking to the next-to-last link. Both walks are O(N). The pLast pointer of a double-ended list is what makes both queue operations O(1).
Why doesn't LinkStack have a constructor?
It doesn't need one. When a LinkStack is created, C++ automatically constructs its theList member, and the LinkList constructor sets pFirst to NULL. That empty list is exactly the state an empty stack should start in.

Quiz

  1. What is an abstract data type?

    Answer

    A data type considered without regard to how it is implemented. It is described by the data it holds and the operations (the interface) that can be performed on that data.

  2. Which LinkList member function does LinkStack's push() call?

    Answer

    insertFirst(). The front of the list serves as the top of the stack, and pop() calls removeFirst().

  3. Why is a double-ended list a good base for a queue?

    Answer

    Its pLast pointer lets insertLast() add items at the rear in O(1) time, and removeFirst() takes them from the front in O(1) time. Those are exactly the two operations a queue needs.

  4. What is a data type?

    Answer

    A set of values plus the operations that can be performed on them. For int, the values are whole numbers in a range and the operations include +, −, *, / and %.

  5. When would you choose an array-based stack over a linked-list stack?

    Answer

    When you know in advance about how many items the stack must hold and you want speed. The array avoids a new or delete for every operation and needs no pointers.

  6. In FirstLastList's removeFirst(), why must pLast be set to NULL when the only link is removed?

    Answer

    Otherwise pLast would keep pointing at the link that was just deleted. The list would be empty at the front yet claim to have a last link, and any code that followed pLast (to peek at the rear of a queue, say) would be using a dangling pointer. Setting both pointers to NULL keeps the empty list consistent.

10

Part II · Abstract Data Types

Hour 10 Specialized Lists

Two variations on the linked list solve two different problems. A sorted list keeps its items in key order as they arrive, which makes it a handy priority queue and even a way to sort. A doubly linked list gives every link a backward pointer as well as a forward one, so you can travel in both directions and remove the last link as easily as the first.

In this hour you'll learn

  • How a sorted linked list keeps its items in order, and how it compares with a sorted array
  • How to insert a new item into a sorted list in C++
  • How a sorted list can sort an array (the list insertion sort), and what that costs
  • How a doubly linked list works: traversal in both directions, and insertion and deletion anywhere
  • When the extra pointers of a doubly linked list are worth their price

The lists of Hour 8 and Hour 9 kept their links in whatever order you happened to insert them, and they could only be walked from front to back. This hour lifts both limits. First you'll keep a list in order by key, and then you'll give each link a second pointer so the list can be walked backward too.

Sorted Lists

In the lists you've seen so far, the order of the links had nothing to do with their keys. In a sorted list, the links are kept in order by key, usually ascending, with the smallest key first.

Most operations work just as before. Finding a link, or deleting a specific one, still means walking from the front and comparing keys along the way. The change is in insertion. A new link can't simply go at the front. It must go in its proper place, just after the last link with a smaller key and just before the first link with a larger one. So every insertion now includes a search.

Sorted Lists and Sorted Arrays

A sorted list does much the same job as the ordered array of Hour 3, and each one has an edge over the other.

  • Insertion is faster in the list. Both structures have to find the right spot. After that, the array must shift every larger item up one cell to open a gap, while the list just changes two pointers. The list also never fills up.
  • Searching is faster in the array. Binary search works by jumping straight to the middle of the remaining range, and that needs random access by index. A list can only be searched link by link, so its searches stay at O(N).

So a sorted list is a good substitute for a sorted array when insertions are frequent or the amount of data is hard to predict. It also makes a simple priority queue, because the smallest item is always at the front, where it can be removed in one step. And, as the next section shows, it can be used to sort an array.

The LinkList Workshop applet from Hour 8 can build a sorted list. When you press New, it asks whether the new list should be sorted or unsorted. Choose sorted, and from then on Ins puts each new link in order. It walks from the start of the list until it reaches the first link with a larger key, then links the new one in just before it. The applet below is set to sorted mode.

C++ Code to Insert an Item

The sortedList.cpp program uses one-number links like those of Hour 9, and its SortedList class keeps the usual pFirst. The interesting member function is insert().

sortedList.cpp Inserting a link in key order
void SortedList::insert(double key)
   {
   Link* pNewLink = new Link(key);
   // pCurrent examines each link; pPrevious remembers the one before it.
   Link* pPrevious = NULL;
   Link* pCurrent = pFirst;
   // Move past every link whose key is smaller than the new key.
   while(pCurrent != NULL && pCurrent->dData < key)
      {
      pPrevious = pCurrent;
      pCurrent = pCurrent->pNext;
      }
   // The new link belongs between pPrevious and pCurrent.
   pNewLink->pNext = pCurrent;
   if(pPrevious == NULL)
      pFirst = pNewLink;
   else
      pPrevious->pNext = pNewLink;
   }

Like remove() in Hour 8, insert() needs two pointers. pCurrent looks at each link in turn, and pPrevious trails one link behind, because the link before the insertion point is the one whose pNext will have to change. The loop moves both pointers forward as long as the current key is smaller than the new one. It can stop in three situations.

  • At the very start. The list is empty, or the new key is no larger than the first key. The loop body never runs, pPrevious is still NULL, and the new link becomes the first link: pFirst = pNewLink.
  • In the middle. pCurrent stops at the first link whose key is at least as big as the new key. The new link goes between pPrevious and pCurrent.
  • Past the end. Every key was smaller, so pCurrent ran off the end and is NULL. The new link goes after pPrevious, the old last link. Its pNext gets the NULL in pCurrent, which correctly marks it as the new end.

The last two situations need exactly the same assignments, which is why the code has only one if. In every case the new link points forward to pCurrent, and whichever pointer used to lead to pCurrent (either pFirst or pPrevious->pNext) now leads to the new link. Unlike in insertFirst(), the order of these two assignments doesn't matter here, because pCurrent keeps its own copy of the address of the next link.

pFirst102040NULLpPreviouspCurrent30pNewLink
Inserting 30 into the sorted list 10, 20, 40. The loop stops with pCurrent at 40, the first key that isn't smaller than 30. The new link points to 40, and the 20 link (pPrevious) points to the new link.

The sortedList.cpp Program

Besides insert(), the SortedList class has isEmpty(), displayList(), and a remove() that takes off the first link and returns its key. It is the removeFirst() of Hour 9 under a new name. Because the list is sorted, the first link always holds the smallest key, so remove() always hands back the minimum.

The main() function inserts 20 and 40 and displays the list. Then it inserts 10, 30 and 50, displays the list again, removes one item, and displays it a third time.

List (first-->last): 20 40
List (first-->last): 10 20 30 40 50
List (first-->last): 20 30 40 50

The three new keys exercise all three cases of insert(). The 10 went to the front (the loop never ran), the 30 went into the middle, and the 50 went at the end (pCurrent ran off the list). The single remove() took away 10, the smallest item.

Efficiency of Sorted Linked Lists

Inserting or deleting an arbitrary item in a sorted list takes about N/2 comparisons on average, because the item could belong anywhere along the list. That's O(N). A search for a key that isn't there can stop as soon as it reaches a larger key, but on average it still covers about half the list. Finding or removing the smallest item, on the other hand, is O(1), since it is always at the front.

That combination makes a sorted list a reasonable priority queue when insertion speed isn't critical. Compare it with the ordered-array priority queue of Hour 7. Both take O(N) time to insert and O(1) time to remove the minimum, but the list's insertion moves no items, and the list never fills up.

Sorted array and sorted list compared
OperationSorted arraySorted linked list
Search for a keyO(log N), binary searchO(N), about N/2 comparisons
Insert a new itemO(N), find the place, then about N/2 movesO(N), about N/2 comparisons and no moves
Find the smallest itemO(1)O(1), it's the first link
SizeFixed when createdGrows and shrinks as needed

List Insertion Sort

A sorted list can serve as a fairly efficient sorting mechanism. Suppose you have an array of unsorted items. Take them out of the array one at a time and insert each one into a sorted list. They fall into order automatically as they go in. Then remove them from the list one at a time, always from the front, and put them back into the array. They come out smallest first, so the array ends up sorted. Lafore calls this the list insertion sort.

insert each item (in order)remove from frontUnsorted array59 69 41 … 35Sorted list15 → 35 → … → 86Sorted array15 35 37 … 86
The list insertion sort. Every item makes one trip into the sorted list, where it drops into place, and one trip back into the array, smallest first.

The listInsertionSort.cpp program works this way. Here the idea is written as a function that sorts a vector in place, using the SortedList class from the previous section.

listInsertionSort.cpp Sorting an array through a sorted list
// Sort v by passing every item through a sorted list.
void listInsertionSort(vector<double>& v)
   {
   SortedList theList;
   int n = v.size();
   // Trip one: each item finds its own place in the list.
   for(int j=0; j<n; j++)
      theList.insert( v[j] );
   // Trip two: the smallest remaining item always comes off first.
   for(int j=0; j<n; j++)
      v[j] = theList.remove();
   }

The program's main() fills an array with 10 random numbers, displays it, sorts it through the list, and displays it again. Because the numbers are random, your output will differ from this run.

Unsorted array: 59 69 41 56 84 15 86 81 37 35
Sorted array:   15 35 37 41 56 59 69 81 84 86

Efficiency of the List Insertion Sort

Start with the comparisons. The first item goes into an empty list without any. Each later item is compared, on average, with about half of the items already in the list before its place is found. Adding that up over all N items gives roughly N²/4 comparisons: about 25 for 10 items and about 250,000 for 1,000 items. That grows as N², so the list insertion sort is still an O(N²) algorithm, in the same class as the bubble sort and insertion sort of Hours 4 and 5.

The copies are where it shines. Each item is copied only twice, once from the array into a link and once from the link back into the array. That makes 2N copies in all: 20 for 10 items, 2,000 for 1,000. The ordinary insertion sort of Hour 5 copies an item every time it shifts one, and the number of shifts grows as N² (about N²/4 on random data, or roughly 250,000 copies for 1,000 items). When copying an item takes much longer than comparing keys, as it does with large records, the list version wins by a wide margin.

Insertion sort and list insertion sort on N random items
Insertion sort (Hour 5)List insertion sort
ComparisonsAbout N²/4About N²/4
CopiesAbout N²/4, growing as N²2N
Copies for 1,000 itemsabout 250,0002,000
Extra memoryOne temporary variableA whole list of N links

The drawback is memory. While the sort runs, the data exists twice, once in the array and once in the list, and every link adds a pointer on top. So the list insertion sort needs about twice as much memory as the array alone. It's a good choice when copies are expensive and memory is plentiful. For large amounts of data, the advanced sorts of Hours 12 to 14 do far better than any O(N²) method.

Doubly Linked Lists

The Trouble with Going Backward

Every list so far has had a built-in limitation. You can only travel forward. The statement pCurrent = pCurrent->pNext takes you to the next link in one step, but there is no matching step to the previous link. To go back, you have to start at pFirst again and walk until you reach the link before the current one.

The book illustrates the problem with a text editor. Suppose the editor stores a document as a linked list, one link per line of text. Moving the cursor down a line is quick, because you just follow pNext. Moving the cursor up a line means finding the previous line, and in a singly linked list that means a walk from the top of the document every time the user presses the up arrow.

A doubly linked list fixes this by giving each link two pointers, pNext to the following link and pPrevious to the one before it.

doublyLinked.cpp A link with pointers in both directions
class Link
   {
   public:
      double dData;
      // Forward: the link after this one.
      Link* pNext;
      // Backward: the link before this one.
      Link* pPrevious;

      Link(double dd) : dData(dd), pNext(NULL), pPrevious(NULL)
         { }
      void displayLink()
         { cout << dData << " "; }
   };

The list object, of class DoublyLinkedList, keeps pointers to both ends, pFirst and pLast, so it is double-ended as well. The first link's pPrevious and the last link's pNext are both NULL.

pFirst664422113355NULLpLast
A doubly linked list. Each link points forward to the next link and back to the previous one, and the list object keeps both pFirst and pLast.

Traversal

Two display functions walk the list in opposite directions. displayForward() starts at pFirst and follows pNext, exactly like displayList() in earlier hours. displayBackward() is its mirror image. It starts at pLast and follows pPrevious.

doublyLinked.cpp Walking the list from the far end
void DoublyLinkedList::displayBackward()
   {
   cout << "List (last-->first): ";
   // Start at the far end and follow the backward pointers.
   Link* pCurrent = pLast;
   while(pCurrent != NULL)
      {
      pCurrent->displayLink();
      pCurrent = pCurrent->pPrevious;
      }
   cout << endl;
   }

Insertion

The DoublyLinkedList class has three insertion functions. insertFirst() puts a link at the front, insertLast() puts one at the end, and insertAfter() puts one just after the link holding a given key. Here is the class, with the first two written out.

doublyLinked.cpp The DoublyLinkedList class and inserting at the ends
class DoublyLinkedList
   {
   private:
      Link* pFirst;
      Link* pLast;
   public:
      DoublyLinkedList() : pFirst(NULL), pLast(NULL)
         { }
      bool isEmpty()
         { return pFirst == NULL; }

      void insertFirst(double dd)
         {
         Link* pNewLink = new Link(dd);
         // The old first link, if there is one, must point back at the newcomer.
         if( isEmpty() )
            pLast = pNewLink;
         else
            pFirst->pPrevious = pNewLink;
         pNewLink->pNext = pFirst;
         pFirst = pNewLink;
         }

      void insertLast(double dd)
         {
         Link* pNewLink = new Link(dd);
         // The old last link, if there is one, must point forward to the newcomer.
         if( isEmpty() )
            pFirst = pNewLink;
         else
            {
            pLast->pNext = pNewLink;
            pNewLink->pPrevious = pLast;
            }
         pLast = pNewLink;
         }

      bool insertAfter(double key, double dd);
      void removeFirst();
      void removeLast();
      bool removeKey(double key);
      void displayForward();
      void displayBackward();
   };

Compare insertFirst() with the Hour 9 version. There is one new job. The old first link used to have a NULL pPrevious, and now it must point back at the new link. If the list was empty, there's no old first link, and the new link becomes the last link as well. insertLast() is the mirror image. The old last link points forward to the new link, the new link points back to it, and pLast moves to the new link.

insertAfter() is the most involved of the three. It first finds the link with the given key, then fits the new link in right after it.

doublyLinked.cpp Inserting after a given key
// Insert dd just after the link holding key.
// Return false if no link holds key.
bool DoublyLinkedList::insertAfter(double key, double dd)
   {
   Link* pCurrent = pFirst;
   while(pCurrent != NULL && pCurrent->dData != key)
      pCurrent = pCurrent->pNext;
   if(pCurrent == NULL)
      return false;

   Link* pNewLink = new Link(dd);
   // Changes 1 and 2: the new link points ahead to current's
   // old successor and back to current.
   pNewLink->pNext = pCurrent->pNext;
   pNewLink->pPrevious = pCurrent;
   // Change 3: the old successor points back at the new link.
   // If current was the last link, pLast moves instead.
   if(pCurrent == pLast)
      pLast = pNewLink;
   else
      pCurrent->pNext->pPrevious = pNewLink;
   // Change 4: current points ahead to the new link.
   pCurrent->pNext = pNewLink;
   return true;
   }

Once the search has found the link, pCurrent points to it, and four pointers must change to fit the new link in after it.

  1. The new link's pNext points to the link that used to follow pCurrent.
  2. The new link's pPrevious points back to pCurrent.
  3. The link that used to follow pCurrent must point back to the new link. If pCurrent was the last link, there is no such link, and pLast moves to the new link instead.
  4. pCurrent's pNext points to the new link.

The order matters again. Change 3 reaches the old successor through pCurrent->pNext, so it must happen before change 4 overwrites that pointer. If change 4 came first, change 3 would reach the new link instead. The new link would end up pointing back at itself, and the old successor would still point back at pCurrent.

pFirst442233NULLpCurrentpLast77pNewLink
insertAfter(22, 77). The new link points ahead to 33 and back to 22. Then 33 points back to 77, and 22 points ahead to 77, for four pointer changes in all.

Deletion

There are three deletion functions: removeFirst(), removeLast() and removeKey(). removeFirst() works as in a double-ended list, with one extra step. After pFirst moves to the second link, that link becomes the new first link, so its pPrevious is set to NULL. If the removed link was the only one, pLast becomes NULL too.

removeLast() is where the backward pointers pay off. In a singly linked list, removing the last link required a walk to find the next-to-last link. Here the next-to-last link is simply pLast->pPrevious, one step away.

doublyLinked.cpp Removing the last link and removing by key
void DoublyLinkedList::removeLast()
   {
   Link* pTemp = pLast;
   // The next-to-last link becomes the end of the list.
   if(pLast->pPrevious == NULL)
      pFirst = NULL;
   else
      pLast->pPrevious->pNext = NULL;
   pLast = pLast->pPrevious;
   delete pTemp;
   }

// Remove the link holding key; return false if there is none.
bool DoublyLinkedList::removeKey(double key)
   {
   Link* pCurrent = pFirst;
   while(pCurrent != NULL && pCurrent->dData != key)
      pCurrent = pCurrent->pNext;
   if(pCurrent == NULL)
      return false;
   // Fix the forward pointer that leads into pCurrent.
   if(pCurrent == pFirst)
      pFirst = pCurrent->pNext;
   else
      pCurrent->pPrevious->pNext = pCurrent->pNext;
   // Fix the backward pointer that leads into pCurrent.
   if(pCurrent == pLast)
      pLast = pCurrent->pPrevious;
   else
      pCurrent->pNext->pPrevious = pCurrent->pPrevious;
   delete pCurrent;
   return true;
   }

Like removeFirst(), removeLast() expects a list that isn't empty, so the caller should check isEmpty() first. When it removes the only link, the list becomes empty, so pFirst is set to NULL, and pLast picks up the NULL from the removed link's pPrevious.

removeKey() searches for the key and then cuts pCurrent out of the list in both directions. In the general case, when the link has neighbors on both sides, it takes two statements.

  • pCurrent->pPrevious->pNext = pCurrent->pNext; makes the link before pCurrent point forward past it.
  • pCurrent->pNext->pPrevious = pCurrent->pPrevious; makes the link after pCurrent point back past it.

The special cases replace one of those statements. If pCurrent is the first link, there's no link before it, so pFirst moves instead. If it's the last link, pLast moves instead. If it's the only link, both happen, and the list is left empty. Notice also that removeKey() doesn't need a pPrevious pointer trailing behind pCurrent, as remove() did in Hour 8. The link being deleted already knows its predecessor.

pFirst44221133NULLpCurrentpLast
removeKey(11). The link before 11 (22) now points forward to 33, and the link after it (33) points back to 22. Then the 11 link is deleted.

The doublyLinked.cpp Program

The main() in doublyLinked.cpp exercises every function. It inserts 22, 44 and 66 at the front and 11, 33 and 55 at the end, then displays the list forward and backward. Next it calls removeFirst(), removeLast() and removeKey(11) and displays the list again. Finally it calls insertAfter(22, 77) and insertAfter(33, 88) and displays the result.

List (first-->last): 66 44 22 11 33 55
List (last-->first): 55 33 11 22 44 66
List (first-->last): 44 22 33
List (first-->last): 44 22 77 33 88

The backward display is the forward one reversed, which shows that the pPrevious pointers are consistent with the pNext pointers. removeFirst() took 66, removeLast() took 55, and removeKey(11) took 11 from the middle. Then 77 went in after 22, and 88 went in after 33. Because 33 was the last link, that final insertion also moved pLast to the 88 link.

Deques, and the Cost of Two Pointers

A doubly linked list is a natural base for the deque of Hour 7, the double-ended queue that can insert and delete at either end. The deque's insertLeft() and insertRight() become insertFirst() and insertLast(), and removeLeft() and removeRight() become removeFirst() and removeLast(). All four are O(1). A double-ended singly linked list can't make that promise, because its removeLast() would need an O(N) walk.

The extra pointer has a price, though. Every insertion and deletion must now change four pointers instead of two, so the code is longer and has more places for a mistake to hide. Each link is bigger too, since it carries two pointers along with its data. When a program never needs to move backward or remove from the end, a singly linked list is the leaner choice.

Summary

  • A sorted list keeps its links in key order. Each insertion walks the list with pCurrent and pPrevious to find the spot just after the last smaller key.
  • Compared with a sorted array, a sorted list inserts faster (nothing moves) and can grow, but it searches more slowly, because binary search needs random access.
  • Inserting or deleting an arbitrary item in a sorted list is O(N), about N/2 comparisons. Finding or removing the smallest item is O(1), which makes the sorted list a simple priority queue.
  • The list insertion sort inserts every array item into a sorted list and then removes them from the front back into the array.
  • It makes about N²/4 comparisons, so it is still O(N²), but only 2N copies, far fewer than the ordinary insertion sort. The price is about twice the memory.
  • A doubly linked list gives each link a pPrevious pointer as well as pNext, so it can be traversed backward, and the last link can be removed in O(1) time.
  • insertAfter() changes four pointers, and removeKey() fixes one forward and one backward pointer, with special cases at the first and last links.
  • A doubly linked list is a natural base for a deque. Its costs are four pointer changes per insertion or deletion instead of two, and a bigger link.

Q&A

Is a sorted list better than a sorted array?
It depends on the mix of operations. If the program mostly searches, the sorted array wins, because binary search is O(log N). If it mostly inserts and deletes, or the amount of data is unpredictable, the sorted list wins, because it never shifts items and never fills up.
If the list insertion sort makes so few copies, why not use it all the time?
Its comparisons still grow as N², and it needs about twice the memory. It beats the ordinary insertion sort when copies are expensive, but for large amounts of data the advanced sorts of Hours 12 to 14 are much faster.
Does a doubly linked list have to keep a pLast pointer?
No. Backward pointers and a pointer to the last link are separate ideas, and a list can have either without the other. The book's doubly linked list has both, which is what lets it insert and remove at the end in O(1) time and display itself backward.
What is the most common mistake in doubly linked list code?
Updating the forward pointers and forgetting one of the backward ones, or doing the updates in the wrong order. The list then looks right when displayed forward and wrong when displayed backward, which is why testing both directions is a good habit.

Quiz

  1. Compared with a sorted array, what is a sorted linked list better at, and what is it worse at?

    Answer

    It is better at insertion (no items move, and it can grow without limit). It is worse at searching, because it can't use binary search, so a search takes O(N) time instead of O(log N).

  2. About how many comparisons and how many copies does the list insertion sort make for N items?

    Answer

    About N²/4 comparisons, but only 2N copies: one into the list and one back into the array for each item.

  3. In the sorted list's insert(), what does it mean if pPrevious is still NULL when the loop ends?

    Answer

    The loop never advanced. Either the list was empty or the new key was no larger than the first key, so the new link must become the first link, and pFirst is set to point to it.

  4. What does the pPrevious pointer in each link of a doubly linked list make possible?

    Answer

    Moving backward through the list. That allows displayBackward(), an O(1) removeLast(), and deletion by key without a trailing pointer. It also lets a text editor move the cursor up a line cheaply.

  5. How many pointers does insertAfter() change when it inserts in the middle of a doubly linked list?

    Answer

    Four: the new link's pNext and pPrevious, the following link's pPrevious, and the current link's pNext.

  6. Why is removing the last link easy in a doubly linked list but slow in a double-ended singly linked list?

    Answer

    Removing the last link means changing the next-to-last link. In a doubly linked list that link is pLast->pPrevious, one step away. In a singly linked list the only way to find it is to walk from pFirst, which is O(N).

11

Part III · Recursion and Quicksort

Hour 11 Recursion

Recursion is a programming technique in which a function calls itself. It sounds like a trick that could never end, yet it is one of the most useful tools in programming, and it is the key to mergesort, quicksort and the tree algorithms later in the book.

In this hour you'll learn

  • How to compute triangular numbers with a loop and with recursion
  • The three characteristics every recursive function shares, and what recursion costs
  • How recursion can produce every arrangement of the letters in a word
  • How binary search can be written recursively, and what "divide and conquer" means
  • How the computer carries out recursion with a stack, and how you can do the same

Recursion is a programming technique in which a function calls itself. The first time you see it, it can look like a dog chasing its own tail. It works because each call tackles a smaller version of the problem than the call before it, until the problem becomes so small that the answer is obvious.

This hour works through several examples: triangular numbers, anagrams, and a recursive version of binary search. Along the way you'll see what makes a recursive function work, what it costs, and how the computer actually carries it out. Recursion is the foundation for the rest of Part III. The Towers of Hanoi and mergesort in Hour 12, the quicksort of Hours 13 and 14, and the tree algorithms of Hours 15 to 20 all depend on it.

Demonstrating Recursion with Triangular Numbers

The Pythagoreans, the circle of ancient Greek mathematicians around Pythagoras, are said to have seen something mystical in the series 1, 3, 6, 10, 15, 21, and so on. Each term is the previous term plus n, where n is the term's position in the series. The second term is 1 + 2 = 3, the third is 3 + 3 = 6, the fourth is 6 + 4 = 10, and the fifth is 10 + 5 = 15.

These are called triangular numbers, because each one can be drawn as a triangle of objects: a row of 1, a row of 2 beneath it, then a row of 3, and so on. The fourth triangular number, 10, is a triangle with rows of 1, 2, 3 and 4 objects. For the code that follows, it helps to tip the triangle over and think of it as columns, a column of 4, then 3, then 2, then 1.

4321
The fourth triangular number drawn as columns of 4, 3, 2 and 1 unit squares. There are 10 squares in all.

Finding the nth Term with a Loop

To find the nth triangular number, add up the column heights: n for the tallest column, n − 1 for the next one, and so on down to 1. A loop does that directly.

triangle.cpp The loop version
int triangle(int n)
   {
   int total = 0;
   // Add the columns, from the tallest (n) down to the shortest (1).
   for(int column = n; column > 0; column--)
      total += column;
   return total;
   }

For n = 4, the loop adds 4, 3, 2 and 1 and returns 10. Nothing about this is surprising. The interesting part is that the same answer can be reached by a completely different route.

Finding the nth Term with Recursion

Look at the columns again. The nth triangular number is the tallest column, which is n high, plus the sum of all the remaining columns. And the remaining columns form a smaller triangle, the (n − 1)th triangular number. So if you already had a function that could compute triangular numbers, you could get the nth one as n plus that function's answer for n − 1.

That is the recursive idea, and the function you "already have" is the very function you're writing.

triangle.cpp The recursive version
int triangle(int n)
   {
   // A triangle with one column holds exactly one object.
   if(n == 1)
      return 1;
   // Otherwise: the tallest column plus the smaller triangle beside it.
   return n + triangle(n-1);
   }

Each call hands a slightly smaller job to another call of the same function. triangle(4) needs the total of the three columns to its right, so it asks triangle(3), which asks triangle(2), and so on. You can picture a line of assistants. Each one takes care of a single column and passes the rest of the job down the line, then adds its own column to whatever answer comes back.

The Base Case

Something has to stop the chain. If triangle() always called itself, the calls would go on forever, or in practice until the program ran out of memory and crashed. The recursive version stops when n is 1. The first triangular number is simply 1, so the function returns 1 without calling itself again. A condition like this, where the function answers directly, is called the base case. Every recursive function needs at least one, and every chain of calls must eventually reach it.

What's Really Happening

To watch the recursion at work, you can add output statements that report each time the function is entered and each time it returns.

triangle.cpp The recursive version, reporting as it goes
int triangle(int n)
   {
   cout << "Entering: n=" << n << endl;
   if(n == 1)
      {
      cout << "Returning 1" << endl;
      return 1;
      }
   // Wait here for the smaller triangle, then add this column.
   int answer = n + triangle(n-1);
   cout << "Returning " << answer << endl;
   return answer;
   }

Here is what it prints when main() calls triangle(5).

Entering: n=5
Entering: n=4
Entering: n=3
Entering: n=2
Entering: n=1
Returning 1
Returning 3
Returning 6
Returning 10
Returning 15

Each time triangle() is called, it gets its own fresh copy of n. The calls with n = 5, 4, 3 and 2 each stop partway through, waiting for the call below them to return. Only the call with n = 1 can answer at once, and it returns 1. Then the waiting calls finish in reverse order. The n = 2 call adds its 2 to the 1 it got back and returns 3. The n = 3 call returns 3 + 3 = 6, the n = 4 call returns 4 + 6 = 10, and finally the n = 5 call returns 5 + 10 = 15 to main().

callscallscallscallstriangle(5)returns 5 + 10 = 15triangle(4)returns 4 + 6 = 10triangle(3)returns 3 + 3 = 6triangle(2)returns 2 + 1 = 3triangle(1)base case, returns 1
The chain of calls for triangle(5). Each call waits for the one below it. The base case answers first, and the return values 1, 3, 6, 10 and 15 flow back up the staircase.

The Triangle applet below replays this process one step at a time. It shows the stack of unfinished calls growing as the function calls itself and shrinking as each call returns, with an "Entering" or "Returning" line for every step.

Characteristics of Recursive Functions

Simple as it is, triangle() has the three characteristics that every recursive function shares.

  1. It calls itself.
  2. When it calls itself, it does so to solve a smaller problem.
  3. There is some version of the problem that is simple enough for the function to solve and return without calling itself. This is the base case.

In triangle(), the smaller problem is the triangle with one column fewer, and the base case is the one-column triangle. Every call moves one step closer to the base case, which guarantees that the chain of calls comes to an end.

The Cost of Recursion

Recursion is not especially efficient. Every function call has overhead. Control has to jump to the start of the function, and the arguments and the address to return to have to be saved, which the computer does by pushing them onto an internal stack. When the function returns, they are popped off again. A recursive triangle(1000) makes 1,000 calls and pays that overhead every time, while the loop version just goes around its loop 1,000 times. The loop is usually faster.

There is a memory cost as well. All the unfinished calls wait on the stack at the same time, so a recursion that goes very deep, many thousands of calls, can use up the space set aside for that stack. This is called a stack overflow, and it usually crashes the program.

Recursion earns its place for a different reason. It can make a problem conceptually simpler. For some problems the recursive solution is short and clear, while a loop version would be long and hard to get right. The anagram example later in this hour is one such problem, and the Towers of Hanoi and the fast sorts of the coming hours are others.

Mathematical Induction

Recursion has a counterpart in mathematics. Mathematical induction is a way of defining something in terms of itself. Using it, the triangular numbers can be defined like this.

  • tri(n) = 1, if n = 1
  • tri(n) = n + tri(n − 1), if n > 1

The first line is the base case, and the second is the recursive step. Put side by side with the recursive C++ function, the definition reads almost line for line like the code. The definition only looks circular. Each use of it refers to a smaller n, and the smallest case is defined outright.

Factorials

Factorials are another classic example of the same pattern. The factorial of n, written n!, is the product of the whole numbers from 1 to n. It can be defined recursively: the factorial of n is n times the factorial of n − 1, and by definition the factorial of 0 is 1.

The first ten factorials
nn!
01
11
22
36
424
5120
6720
75,040
840,320
9362,880
factorial.cpp Factorials by recursion
int factorial(int n)
   {
   // By definition, 0! is 1. This is the base case.
   if(n == 0)
      return 1;
   // n! is n times the factorial of the next smaller number.
   return n * factorial(n-1);
   }

The function is triangle() with two small changes: the base case is n = 0 instead of n = 1, and the operation is multiplication instead of addition. Factorials grow far faster than triangular numbers, though.

Demonstrating Recursion with Anagrams

Here is a problem where recursion gives a neat solution that would be hard to find any other way. Suppose you want to list all the anagrams of a word. In this hour, an anagram means any arrangement of the word's letters, whether or not the result is a real word. The three-letter word cat has six: cat, cta, atc, act, tca and tac.

Try listing the anagrams of a five-letter word by hand and you'll soon find how easy it is to miss some or write others twice. You need a system.

The Algorithm

The system Lafore uses works on the rightmost letters of the word. To anagram a word of n letters:

  1. Anagram the rightmost n − 1 letters.
  2. Rotate all n letters.
  3. Repeat these steps n times.

To rotate a group of letters means to shift every letter one position to the left, except the leftmost letter, which moves around to the right end. Rotating cat gives atc, rotating again gives tca, and a third rotation brings back cat.

Each rotation gives a different letter the first position of the group. While that letter sits in front, step 1 produces every arrangement of the letters behind it. After n rotations, each of the n letters has had its turn in front, and the group is back the way it started.

That leaves the question of how to anagram the rightmost n − 1 letters in step 1. The answer is to do it the same way, by anagramming the rightmost n − 2 of them, rotating those n − 1, and repeating. That is the recursion. The group shrinks by one letter at each level, until it is a single letter, which has only one arrangement and needs no work. That is the base case.

beforec0a1t2s3
Rotating the rightmost three letters of cats. The t and the s each shift one cell left, and the a moves around to the right end.
afterc0t1s2a3
The result is ctsa. The c wasn't part of the rotated group, so it stays where it was.

Here is how the algorithm handles cat. The outer level rotates all three letters, three times. Before each rotation, the inner level anagrams the last two letters, which means showing the word, swapping the last two letters by rotating them, and showing the word again.

Anagramming cat
TurnLetter in frontInner level showsThen all three rotate
1ccat, ctacat becomes atc
2aatc, actatc becomes tca
3ttca, tactca becomes cat

The C++ Code

The anagram program keeps the word in a string data member called workStr, with its length in size and a running total of words displayed in count. The recursive member function doAnagram() takes one argument, newSize, the number of letters at the right end of the word that it should anagram. The first call passes the length of the whole word.

anagram.cpp The recursive member function
// Produce every arrangement of the rightmost newSize letters.
void doAnagram(int newSize)
   {
   // A single letter has only one arrangement.
   if(newSize == 1)
      return;
   // Give each letter of the group a turn at the front.
   for(int turn = 0; turn < newSize; turn++)
      {
      // Arrange everything behind the front letter.
      doAnagram(newSize - 1);
      // Only the two-letter level displays, so each word appears once.
      if(newSize == 2)
         displayWord();
      // Bring the next letter of the group to the front.
      rotate(newSize);
      }
   }

The loop runs newSize times, once for each letter that will take a turn at the front of the group. Inside it, the function calls itself to anagram the smaller group, then rotates its own group. The display happens only at the level where newSize is 2. At that moment the call to doAnagram(1) has just returned without doing anything, so the word holds a fresh arrangement that no other level has displayed. If every level displayed the word, the same arrangement would be printed several times.

anagram.cpp Rotating and displaying
// Rotate the rightmost newSize letters one place to the left.
void rotate(int newSize)
   {
   int position = size - newSize;
   char first = workStr[position];
   // Slide the rest of the group one place left.
   for(int j = position + 1; j < size; j++)
      workStr[j-1] = workStr[j];
   // The old front letter goes to the far right.
   workStr[size-1] = first;
   }

// Show the current arrangement, numbered, six to a line.
void displayWord()
   {
   count++;
   cout << setw(2) << count << " " << workStr << "   ";
   if(count % 6 == 0)
      cout << endl;
   }

rotate() works out where its group begins. If the word has 4 letters and the group has 3, the group starts at position 4 − 3 = 1. It saves the group's first letter, slides the other letters one cell to the left, and drops the saved letter into the last cell. displayWord() numbers each arrangement and starts a new line after every sixth one. It uses setw(), so the program includes the iomanip header.

The Output

Given the word cats, the program prints all 24 arrangements.

 1 cats    2 cast    3 ctsa    4 ctas    5 csat    6 csta
 7 atsc    8 atcs    9 asct   10 astc   11 acts   12 acst
13 tsca   14 tsac   15 tcas   16 tcsa   17 tasc   18 tacs
19 scat   20 scta   21 satc   22 sact   23 stca   24 stac

The pattern is easy to see. The first six arrangements all begin with c, the next six with a, then six with t and six with s. Each block of six is every arrangement of the other three letters, produced by the level below while one letter held the front position.

A word of n letters has n! arrangements: n choices for the first letter, n − 1 for the second, and so on. So cats, with 4 letters, has 4! = 24, and a five-letter word has 5! = 120. A 10-letter word has 10! = 3,628,800, which would take a while to print. If the word contains a repeated letter, some arrangements come out more than once. The algorithm treats the two t's in tot as different letters, so it prints tot, tto and ott twice each.

Remember the binary search of Hour 3. It finds a key in an ordered array by repeatedly halving the range: look at the middle cell, decide which half must hold the key, and repeat on that half. In Hour 3 the find() function did this with a loop that adjusted lowerBound and upperBound each time around. The same algorithm can be written recursively, and it's a natural fit, because each step is a smaller copy of the same problem, a search of half the range.

The binarySearch.cpp program adds these member functions to the OrdArray class of Hour 3, which keeps its items in a vector<double> called v and their count in nElems.

binarySearch.cpp Binary search, the recursive way
// Search the whole array; return the key's index, or nElems if absent.
int find(double searchKey)
   {
   return recFind(searchKey, 0, nElems-1);
   }

// Search the cells from lowerBound through upperBound.
int recFind(double searchKey, int lowerBound, int upperBound)
   {
   // An empty range means the key isn't in the array.
   if(lowerBound > upperBound)
      return nElems;
   int curIn = (lowerBound + upperBound) / 2;
   if(v[curIn] == searchKey)
      return curIn;
   // Otherwise search only the half that could hold the key.
   if(v[curIn] < searchKey)
      return recFind(searchKey, curIn+1, upperBound);
   else
      return recFind(searchKey, lowerBound, curIn-1);
   }

The user of the class calls find() just as in Hour 3, and never needs to know that recursion is involved. find() simply starts the search with the full range, 0 to nElems − 1. The real work happens in recFind(), which only find() needs to call.

Where the Hour 3 loop changed lowerBound or upperBound and went around again, recFind() passes the new bounds to a fresh call of itself. It has two base cases, one for each way a search can end. Either the middle cell holds the key (found), or the range has become empty because lowerBound has passed upperBound (not found, reported by returning nElems). Our version checks for the empty range first, so it never looks at a cell outside the range. The book's version tests for a match first. Both give the same answers.

Suppose the array holds 0, 11, 22, 33, 44, 55, 66, 77, 88 and 99, and you search for 55. The first call looks at index 4 (44), the second at index 7 (77), and the third finds 55 at index 5.

00111222333444555666777888999curInlowerBoundupperBound
First call, recFind(55, 0, 9). The middle cell, index 4, holds 44. That's less than 55, so the key must be in the upper half.
00111222333444555666777888999curInlowerBoundupperBound
Second call, recFind(55, 5, 9). Index 7 holds 77, which is too big. A third call, recFind(55, 5, 6), finds 55 at index 5.

Each call works on half the range of the call before it, so the recursion never goes deep. A 10-item array needs at most 4 cells examined, and a million-item array at most about 20, the same counts as in Hour 3. The recursive version is still O(log N). It does pay the small overhead of a function call at each step, which the loop version avoids.

Divide and Conquer

The recursive binary search is an example of the divide-and-conquer approach. You divide a big problem into smaller problems and solve each one separately. Each smaller problem is divided the same way, until you reach problems simple enough to solve directly. Binary search is an unusually easy case, because after dividing it only has to solve one of the halves, since the key can't be in both. Mergesort (Hour 12) and quicksort (Hours 13 and 14) also divide their data in two, but they have to deal with both halves, so their recursive functions call themselves twice.

Recursion Versus Stacks

You've seen that each call of triangle() has its own copy of n, and that unfinished calls wait while deeper calls do their work. The computer keeps track of all this with a stack. When a function is called, the code the compiler generates pushes the arguments and the return address, the place in the caller where execution should continue afterward, onto an internal stack. The function reads its arguments from the top of that stack. When it returns, the return address and the arguments are popped off, which uncovers the values belonging to the call that is still waiting underneath.

So recursion is a stack in disguise. The last call made is the first one to finish, which is exactly the last-in, first-out rule of Hour 6. It follows that any recursive algorithm can be rewritten to use a stack of your own in place of the recursive calls.

Simulating What the Compiler Does

The book first shows a literal simulation of what the compiler does for triangle(). It defines a small class that bundles an argument together with a return address, keeps a stack of objects of that class, and runs the whole computation as a loop around a switch statement. Each case of the switch handles one numbered step of calling or returning, and a variable that says which step comes next plays the part of the return address. The result is a mechanical imitation of calls and returns, and it produces the same answers as the recursive function.

That simulation is not easy reading, which is rather the point. It shows how much bookkeeping recursion quietly does for you. Lafore then boils the idea down to a much simpler function.

A Simpler Stack Version

stackTriangle.cpp Triangular numbers with an explicit stack
int stackTriangle(int n)
   {
   // Room for far more values than we will ever push.
   StackX theStack(10000);
   // Phase 1: push the column heights, as the calls did going down.
   for(int column = n; column > 0; column--)
      theStack.push(column);
   // Phase 2: pop and add them, as the returns did coming back up.
   int answer = 0;
   while( !theStack.isEmpty() )
      answer += theStack.pop();
   return answer;
   }

The function works in two phases that mirror the two halves of the recursive trace. The first loop pushes n, n − 1, and so on down to 1, just as the recursive calls went down from triangle(5) to triangle(1), each one leaving its own n behind on the stack. The second loop pops the values and adds them up, just as the returns came back up, each adding its own n. The function uses the StackX class of Hour 6, changed to hold int values instead of doubles. The size, 10,000, only needs to be larger than any n the function will be given.

theStack50413223145top
stackTriangle(5) at the halfway point. The first loop has pushed 5, 4, 3, 2 and 1. Now the second loop pops them, 1 first, and adds each one to answer, giving 15.

Why Use a Stack

Replacing recursion with your own stack is worthwhile for a few reasons. Sometimes the stack version is more efficient, because it avoids the overhead of real function calls and stores only what it needs, here a single number per level instead of arguments plus a return address. It can also avoid stack overflow, since your own stack can be made as big as memory allows, while the system's call stack is usually much more limited. And writing the stack version is a good way to see what recursion really does behind the scenes.

For triangular numbers, the plain loop at the start of this hour beats both versions. The stack technique matters most for algorithms that have no such simple loop, such as some of the sorting and tree algorithms in the hours ahead.

Summary

  • A recursive function calls itself. Each call works on a smaller version of the problem, until it reaches a base case that it can solve without calling itself.
  • The nth triangular number is n plus the (n − 1)th, and the first is 1. The recursive triangle() follows that definition directly.
  • Every call gets its own copy of its arguments. The calls go down to the base case, and then the returns come back up, each adding its own part of the answer.
  • Every recursive function calls itself, does so to solve a smaller problem, and has a base case. Without a base case, the recursion runs until the program crashes.
  • Recursion is usually slower than a loop because of the overhead of each call, and very deep recursion can overflow the stack. It is used because it makes some problems conceptually simpler.
  • Mathematical induction defines things in terms of themselves, just as recursion does. Factorials fit the same pattern: n! = n × (n − 1)!, with 0! = 1.
  • To anagram a word of n letters, anagram the rightmost n − 1 letters, rotate all n, and repeat n times. A word of n letters has n! arrangements.
  • Binary search can be written recursively. It is still O(log N), and it is an example of divide and conquer, in which only one of the two halves has to be solved.
  • The compiler implements function calls with a stack of arguments and return addresses, so any recursive algorithm can be rewritten with an explicit stack.

Q&A

Is a recursive solution ever faster than a loop?
Seldom. Each recursive call pushes arguments and a return address and then pops them again, and a loop avoids that work. Recursion's value lies in clarity. For some problems, such as the anagrams in this hour or the sorts to come, the recursive version is much easier to write and to trust.
If every call runs the same function, how can each one have its own n?
Each call gets its own set of arguments and local variables, stored in its own section of the stack. The n that belongs to triangle(5) sits lower in the stack, untouched, while triangle(4) works with its own n on top.
Can every recursive function be rewritten without recursion?
Yes. At worst you imitate what the compiler does, with a stack of your own, as stackTriangle() does. Sometimes a plain loop is enough, as with triangular numbers and factorials.
What exactly happens if I forget the base case?
The function keeps calling itself, and every unfinished call occupies space on the stack. Soon the stack runs out of room and the program crashes with a stack overflow.

Quiz

  1. What are the three characteristics of a recursive function?

    Answer

    It calls itself. When it does, it is to solve a smaller problem. And there is a base case, a version of the problem simple enough to solve without calling itself.

  2. What happens if a recursive function has no base case?

    Answer

    It calls itself endlessly. In practice the unfinished calls fill up the stack and the program crashes.

  3. What does triangle(4) return, and how many calls of triangle() does it take?

    Answer

    It returns 10 (4 + 3 + 2 + 1), using four calls: with n = 4, 3, 2 and 1.

  4. How many anagrams does a five-letter word with no repeated letters have?

    Answer

    5! = 5 × 4 × 3 × 2 × 1 = 120.

  5. Why is a recursive function usually slower than an equivalent loop?

    Answer

    Each call has overhead. Control must be transferred to the function, and its arguments and return address must be pushed onto the stack and later popped off. A loop does none of that.

  6. What does "divide and conquer" mean, and how does binary search use it?

    Answer

    It means splitting a problem into smaller problems and solving each one, often recursively. Binary search splits the range in half and then only has to search the one half that could contain the key.

12

Part III · Recursion and Quicksort

Hour 12 Applied Recursion

This hour applies recursion to two classic problems. The Towers of Hanoi puzzle turns out to need only a few lines of code, and mergesort sorts data far faster than the O(N²) methods you have used so far.

In this hour you'll learn

  • What the Towers of Hanoi puzzle is, and the legend that goes with it
  • How to see a pile of disks as smaller piles, and solve it in three recursive steps
  • Why n disks need 2ⁿ − 1 moves
  • How to merge two sorted arrays into one
  • How mergesort sorts by splitting and merging, and how its C++ code works
  • Why mergesort runs in O(N log N) time, and what it costs in memory

The Towers of Hanoi

The Towers of Hanoi is an ancient puzzle made of three columns and a set of disks. Every disk has a different diameter, and each has a hole through its center so it can slide down over a column. The columns are called towers A, B and C.

At the start, every disk is stacked on tower A, widest at the bottom and narrowest at the top. Your job is to move the whole stack to tower C.

Two rules make this harder than it sounds. You may move only one disk at a time, always the top disk of some tower. And you may never set a disk down on a disk smaller than itself. Tower B is there to help, and you can use it as often as you like.

4321ABC
The starting position with four disks. All of them sit on tower A, largest at the bottom. The puzzle is solved when the same pile stands on tower C.

Lafore passes on the legend that usually travels with the puzzle. Somewhere in India, the story goes, a remote temple holds three towers encrusted with diamonds and a pile of 64 golden disks. Monks work in shifts around the clock, shifting the pile from one tower to another under the same two rules. When the final disk comes to rest, the world will end.

You can relax. As you'll see shortly, 64 disks need 2⁶⁴ − 1 moves. Working at one move per second without a single break, the monks would need about 585 billion years.

The Towers Workshop Applet

The book's Towers Workshop applet lets you choose how many disks to play with. You can then solve the puzzle yourself by dragging disks from tower to tower. You can also press Step to see the solution one move at a time, or Run to watch the applet solve it from start to finish.

Lafore suggests working a few puzzles by hand before you let the applet show you how. Three disks take 7 moves and four take 15. On the first tries it is easy to trap yourself, with a small disk sitting on exactly the tower you need next. After a little practice a rhythm appears, and that rhythm is what the recursive solution captures.

The re-creation below behaves like the original, except that you click instead of drag. Click the tower whose top disk you want to move, then click the tower where it should go. A counter shows your moves next to the minimum, 2ⁿ − 1 for n disks.

Moving Subtrees

To describe the solution, it helps to give the piles names. Lafore calls the starting pile of disks a tree, and any smaller pile sitting on top of it a subtree. (The binary trees that begin in Hour 15 are a separate idea; here the words simply describe piles of disks.) With four disks, the top three form a subtree, the top two form a smaller subtree, and the top disk by itself is a subtree of one.

Now think about the largest disk, number 4. It can't move while anything sits on top of it. It also needs an empty tower to land on, because every other disk is smaller than it is. So at the moment disk 4 travels from A to C, tower C must be empty and the other three disks must all be stacked on tower B.

That one observation splits the puzzle into three jobs. First, move the three-disk subtree from A to B. Second, move disk 4 from A to C. Third, move the three-disk subtree from B to C, where it lands on top of disk 4.

4A321BC
Job 1, moves 1 to 7. The three-disk subtree has moved from A to B, which frees disk 4.
A321B4C
Job 2, move 8. Disk 4 goes straight to the empty tower C.
AB4321C
Job 3, moves 9 to 15. The subtree moves from B onto disk 4, and the puzzle is solved.

The middle job is a single move. The first and third jobs are the original puzzle again, only with three disks and with the towers playing different parts. Each of those can be split the same way, into a two-disk job, one move, and another two-disk job. A problem that contains smaller copies of itself is exactly the kind of problem recursion handles well.

The Recursive Algorithm

To state the solution in general terms, name the towers by the part they play instead of by letter. S is the source, where the pile starts, D is the destination, where it must end up, and I is the intermediate tower, the spare. To move a pile of n disks from S to D, you follow three steps.

  1. Move the subtree made of the top n − 1 disks from S to I.
  2. Move the remaining disk, the largest one, from S to D.
  3. Move the subtree from I to D.

The base case is a subtree of just one disk. Nothing sits on top of it, so nothing has to be cleared away, and you move it straight from S to D. Every recursive call works on a pile one disk smaller than its caller's pile, so the calls always get down to this case.

The part that takes getting used to is that the towers trade roles from one call to the next. In step 1 the subtree has to end up on I, so for that call I is the destination and D serves as the spare. In step 3 the subtree starts on I, so I is the source and S becomes the spare. Here is the complete plan for three disks, written as jobs inside jobs.

  • Move 3 disks from A to C, with B as the spare:
    • Move 2 disks from A to B, with C as the spare:
      • Disk 1 from A to C
      • Disk 2 from A to B
      • Disk 1 from C to B
    • Disk 3 from A to C
    • Move 2 disks from B to C, with A as the spare:
      • Disk 1 from B to A
      • Disk 2 from B to C
      • Disk 1 from A to C

Read the innermost lines from top to bottom and you have the seven moves that solve the three-disk puzzle.

The towers.cpp Program

The C++ version follows the three steps almost word for word. The function doTowers() takes the number of disks in the pile, then the names of the three towers in the order source, intermediate, destination. Disks are numbered by size, 1 for the smallest, so a pile of topN disks always has disk topN at its bottom.

towers.cpp Solving the Towers of Hanoi recursively
// towers.cpp
// Solves the Towers of Hanoi puzzle recursively.
#include <iostream>
using namespace std;

// Move the pile made of disks 1..topN from tower src to tower dest,
// using tower inter as a parking place along the way.
void doTowers(int topN, char src, char inter, char dest)
{
   // Base case: a pile of one disk simply moves.
   if(topN == 1)
   {
      cout << "Disk 1 from " << src << " to " << dest << endl;
      return;
   }
   // Park the smaller disks on the spare tower.
   doTowers(topN - 1, src, dest, inter);

   // The biggest disk of this pile is now free to move.
   cout << "Disk " << topN << " from " << src
        << " to " << dest << endl;

   // Bring the smaller disks back on top of it.
   doTowers(topN - 1, inter, src, dest);
}

int main()
{
   int nDisks = 3;
   doTowers(nDisks, 'A', 'B', 'C');
   return 0;
}

When topN is 1, the function prints a single move and returns. Otherwise it makes two recursive calls with one print statement between them.

Watch the order of the tower arguments. The first call passes src, dest, inter, so the spare and the destination swap places and the smaller disks go to the spare. The second call passes inter, src, dest, so the pile starts from the spare and the original source becomes the new spare.

main() calls doTowers(nDisks, 'A', 'B', 'C') with nDisks set to 3. The program prints one line per move.

Disk 1 from A to C
Disk 2 from A to B
Disk 1 from C to B
Disk 3 from A to C
Disk 1 from B to A
Disk 2 from B to C
Disk 1 from A to C

These are the same seven moves as the nested plan. Each call to doTowers() prints exactly one line, so the number of calls equals the number of moves. The calls never nest deeply, though. At any moment the chain of unfinished calls holds at most one call per pile size, so even 64 disks would need only 64 levels of recursion.

Now count the moves. Write moves(n) for the number of moves needed with n disks. One disk takes one move. A larger pile needs the moves for n − 1 disks, then one move, then the moves for n − 1 disks again, so moves(n) = 2 × moves(n − 1) + 1.

Starting from moves(1) = 1, that rule gives 3, 7, 15, 31 and so on. Each result is one less than a power of 2, which means moves(n) = 2ⁿ − 1.

Moves needed to solve the Towers of Hanoi
DisksMoves (2ⁿ − 1)
11
23
37
415
531
101,023
201,048,575
6418,446,744,073,709,551,615

Every extra disk doubles the work and adds one move. Growth of this kind is called exponential, and it leaves even N² behind almost at once. Twenty disks already need more than a million moves. Sixty-four disks need so many that the monks, at one move per second, will be busy for those 585 billion years.

Mergesort

The second application of recursion is a sort, and a much faster one than those you've met so far. The bubble sort of Hour 4 and the insertion sort of Hour 5 take O(N²) time. Mergesort takes O(N log N).

For large arrays the gap is enormous. With N = 10,000, N² is 100,000,000, while N × log₂N is only about 133,000, roughly 750 times smaller. If each step took one microsecond, the simple sort would run for about 100 seconds and mergesort would be done in about 0.13 seconds.

Mergesort is also easy to understand and fairly easy to code, easier than the quicksort of Hour 13. Its one real drawback is memory, because it needs a second array, called the workspace, as large as the array being sorted. If your data only just fits in memory, mergesort is ruled out. If memory is plentiful, it is an excellent choice.

Merging Two Sorted Arrays

The heart of mergesort is a simpler job, merging two arrays that are already sorted. The result is a third array that holds all the items of both, also in sorted order.

Think of two piles of exam papers, each already sorted by student name. To combine them, you look at the top paper of each pile, move the one whose name comes first in the alphabet onto a new pile, and repeat. When one pile runs out, you move the rest of the other pile across as it is, since it is already in order. Merging arrays works exactly the same way.

The book's example merges array A, which holds 4 items, with array B, which holds 6, into array C, which has room for 10. The program keeps an index into each array. At every step it compares the front items of A and B, copies the smaller one into the next free cell of C, and advances the index of the array it copied from.

Merging A = {23, 47, 81, 95} with B = {7, 14, 39, 55, 62, 74}
StepComparisonCopy into C
123 with 77 from B
223 with 1414 from B
323 with 3923 from A
439 with 4739 from B
555 with 4747 from A
655 with 8155 from B
762 with 8162 from B
874 with 8174 from B, which is now empty
9none81 from A
10none95 from A

After step 8, B has nothing left, so there is nothing to compare. The last two steps simply copy the remaining items of A, which are already in order. C ends up holding 7 14 23 39 47 55 62 74 81 95. Ten items found their places with only eight comparisons.

A230471812953aDex
Array A after step 5. Its first two items (dimmed) are already in C, and aDex points at 81.
B70141392553624745bDex
Array B after step 5. Three items have been copied, and bDex points at 55.
C7014123239347456789cDex
Array C after step 5 holds the five smallest items in order. Step 6 compares 55 with 81 and copies 55 into cell 5, where cDex points.

Here is the merge as a C++ function, with a small main() that runs the example. The arrays are ordinary C++ arrays of int, and each array's size is passed in beside it.

merge.cpp Merging two sorted arrays into a third
// merge.cpp
// Merges two sorted arrays into a third, larger array.
#include <iostream>
using namespace std;

// Copy every item of the sorted arrays arrayA and arrayB into
// arrayC, which must have room for sizeA + sizeB items.
void merge(int arrayA[], int sizeA,
           int arrayB[], int sizeB,
           int arrayC[])
{
   int aDex = 0;
   int bDex = 0;
   int cDex = 0;

   // While both inputs still hold items, copy the smaller front item.
   while(aDex < sizeA && bDex < sizeB)
   {
      if(arrayA[aDex] <= arrayB[bDex])
         arrayC[cDex++] = arrayA[aDex++];
      else
         arrayC[cDex++] = arrayB[bDex++];
   }
   // One input is used up. Copy what remains of the other one.
   // (Only one of these two loops will find anything to do.)
   while(aDex < sizeA)
      arrayC[cDex++] = arrayA[aDex++];
   while(bDex < sizeB)
      arrayC[cDex++] = arrayB[bDex++];
}

int main()
{
   int arrayA[] = {23, 47, 81, 95};
   int arrayB[] = {7, 14, 39, 55, 62, 74};
   int arrayC[10];

   merge(arrayA, 4, arrayB, 6, arrayC);

   for(int j = 0; j < 10; j++)
      cout << arrayC[j] << " ";
   cout << endl;
   return 0;
}

The first while loop runs as long as both arrays still have items to offer. Each pass compares the two front items, copies the smaller one into C, and moves two indexes forward, cDex and the index of the array that supplied the item. A statement such as arrayC[cDex++] = arrayA[aDex++] does the copy and both increments at once.

When the first loop ends, one of the arrays has run dry. The next two loops copy whatever is left of the other one. Only one of them does any work, because the array that emptied first fails its loop test straight away. The program's output is the merged array.

7 14 23 39 47 55 62 74 81 95

Sorting by Merging

Merging only works on arrays that are already sorted, which at first seems to leave you where you started. Mergesort gets around this with a simple plan. Divide the array in half, sort each half, and then merge the two sorted halves into one sorted array.

Each half is sorted the same way, by mergesort itself. The half is divided into two quarters, each quarter is sorted, and the two sorted quarters are merged. The quarters in turn are split into eighths, and so on down. This is the divide-and-conquer approach you met with recursive binary search in Hour 11.

The splitting stops when a piece holds a single item. An array of one item is already sorted, so that is the base case. From there the work runs back up. Pairs of single items merge into sorted runs of 2, pairs of those runs merge into runs of 4, then 8, until one run fills the whole array.

64 21 33 70 12 85 44 364 21 33 7012 85 44 364 2133 7012 8544 364213370128544321 6433 7012 853 4421 33 64 703 12 44 853 12 21 33 44 64 70 85
Mergesort on eight items. The upper half of the picture splits the array into halves until every piece holds one item. The highlighted lower half merges the pieces back into sorted runs of 2, then 4, then all 8.

The picture shows the work level by level, but the program does it in a different order. Each call finishes sorting its lower half before it even starts on the upper half, so the left side of the array is completely sorted before the right side is touched. For the eight items above, the seven merges happen in this order.

The order of merges when sorting 64 21 33 70 12 85 44 3
MergeCellsResult
10–121 64
22–333 70
30–321 33 64 70
44–512 85
56–73 44
64–73 12 44 85
70–73 12 21 33 44 64 70 85

The array's size doesn't have to be a power of 2. When a range holds an odd number of items, its two halves differ in size by one. Twelve items split into 6 and 6, each 6 splits into 3 and 3, and each 3 splits into 2 and 1. Merging works just as well on runs of unequal length.

The mergeSort Workshop Applet

The mergeSort Workshop applet runs the algorithm on a row of bars of different heights. As it works, short sorted runs appear at the left end and grow, merging with their neighbors into longer and longer runs that spread to the right. The applet marks the range it is currently merging, and it counts comparisons and copies as it goes.

The re-creation below does the same. It marks the two runs being merged, reports each comparison and copy in a message line, and keeps both counts on screen.

C++ Code for Mergesort

The mergeSort.cpp program wraps the data in a class called DArray. The class keeps the items in a vector<double> named theVect and counts them in nElems. Besides the usual insert() and display(), it offers a public mergeSort() function, backed by two private helpers that do the real work.

mergeSort.cpp The DArray class and the recursive part of mergesort
// mergeSort.cpp
// Sorts an array of doubles with a recursive mergesort.
#include <iostream>
#include <vector>
using namespace std;

class DArray
{
private:
   // The data, and the number of cells actually in use.
   vector<double> theVect;
   int nElems;

   void recMergeSort(vector<double>& workSpace,
                     int lowerBound, int upperBound);
   void merge(vector<double>& workSpace,
              int lowPtr, int highPtr, int upperBound);
public:
   DArray(int max) : nElems(0)
   {
      theVect.resize(max);
   }
   void insert(double value)
   {
      theVect[nElems++] = value;
   }
   void display();
   void mergeSort();
};

// The public entry point. It creates one scratch array, the same
// size as the data, and every recursive call shares it.
void DArray::mergeSort()
{
   vector<double> workSpace(nElems);
   recMergeSort(workSpace, 0, nElems - 1);
}

void DArray::recMergeSort(vector<double>& workSpace,
                          int lowerBound, int upperBound)
{
   // Base case: a range of one item is already sorted.
   if(lowerBound >= upperBound)
      return;

   // Split the range in the middle and sort each half.
   int mid = (lowerBound + upperBound) / 2;
   recMergeSort(workSpace, lowerBound, mid);
   recMergeSort(workSpace, mid + 1, upperBound);

   // Both halves are now sorted runs, so merge them into one.
   merge(workSpace, lowerBound, mid + 1, upperBound);
}

mergeSort() is the only sorting function a user of the class calls. It creates the workspace, a vector exactly as long as the data, and hands it to recMergeSort() together with the bounds of the whole array.

recMergeSort() is the whole algorithm in a few lines. If the range holds a single item, it returns at once, since there is nothing to sort. (The test uses >= so that an empty array is handled safely too.) Otherwise it finds the middle of the range, calls itself on the lower half, calls itself on the upper half, and finally merges the two halves, which by then are both sorted.

The merging is done by a member function also named merge(), but it differs from the merge.cpp version in two ways.

mergeSort.cpp Merging two neighboring runs through the workspace
// Merge the sorted run lowPtr..highPtr-1 with the sorted run
// highPtr..upperBound. The two runs sit side by side in theVect.
void DArray::merge(vector<double>& workSpace,
                   int lowPtr, int highPtr, int upperBound)
{
   // Remember where the combined range starts, where the lower run
   // ends, and how many items there are altogether.
   int start = lowPtr;
   int mid = highPtr - 1;
   int count = upperBound - start + 1;
   int w = 0;

   // Take the smaller front item until one run is used up.
   while(lowPtr <= mid && highPtr <= upperBound)
   {
      if(theVect[lowPtr] <= theVect[highPtr])
         workSpace[w++] = theVect[lowPtr++];
      else
         workSpace[w++] = theVect[highPtr++];
   }
   // Copy the leftovers of whichever run still has items.
   while(lowPtr <= mid)
      workSpace[w++] = theVect[lowPtr++];
   while(highPtr <= upperBound)
      workSpace[w++] = theVect[highPtr++];

   // Copy the merged run back into the same stretch of theVect.
   for(int k = 0; k < count; k++)
      theVect[start + k] = workSpace[k];
}

First, its two input runs are not separate arrays. They lie next to each other inside theVect. The lower run occupies cells lowPtr through mid, and the upper run occupies highPtr (which is always mid + 1) through upperBound. The pointers lowPtr and highPtr play the parts that aDex and bDex played before.

Second, the merged items go into the workspace, starting at cell 0 each time, and the final for loop copies them back over the cells the two runs came from. That round trip is the reason the workspace exists. If the merge wrote straight into theVect, it would overwrite items of the lower run before they had been merged.

main() creates a DArray, inserts twelve numbers (64, 21, 33, 70, 12, 85, 44, 3, 99, 0, 108 and 36), displays them, calls mergeSort(), and displays them again. display() is a plain loop that prints every item.

64 21 33 70 12 85 44 3 99 0 108 36
0 3 12 21 33 36 44 64 70 85 99 108

Efficiency of the Mergesort

Mergesort runs in O(N log N) time. The simplest way to see why is to count copies, and the simplest arrays to count are those whose size is a power of 2.

Look back at the eight-item picture. Below the single items there are log₂8 = 3 levels of merging, producing runs of 2, runs of 4 and the final run of 8. At each level every item takes part in exactly one merge, so each level copies all 8 items into the workspace and all 8 back again. In general there are log₂N levels, so the items are copied into the workspace N × log₂N times in all, and the same number of times back.

Comparisons take a little more thought, because their number depends on the data. Merging two runs that hold n items between them takes at most n − 1 comparisons. That worst case happens when the items of the two runs interleave, so that neither run empties until the very end.

For two runs of equal length, the best case is n/2 comparisons. It happens when every item of one run is smaller than every item of the other; that run is used up after n/2 comparisons, and the rest is copied without any.

Copies and comparisons in mergesort, for N a power of 2
Nlog₂NCopies into workspace (N × log₂N)Total copiesComparisons, max (min)
21241 (1)
428165 (4)
83244817 (12)
1646412849 (32)
325160320129 (80)
646384768321 (192)
12878961,792769 (448)

Two patterns run through the comparison column. The minimum is exactly half of N × log₂N, since in the best case each level needs only N/2 comparisons. The maximum is N × log₂N − N + 1. There are N − 1 merges in all, and each one needs at most one comparison fewer than the number of items it handles.

The eight-item example sits right on the worst case. Its seven merges used 1 + 1 + 3 + 1 + 1 + 3 + 7 = 17 comparisons and made 48 copies, just as the table predicts, because every one of its merges interleaved its runs completely.

Now compare the simple sorts. Bubble sort on 128 items makes 128 × 127 / 2 = 8,128 comparisons, more than ten times mergesort's worst case of 769. The gap keeps widening as N grows, because N² grows far faster than N × log₂N.

Summary

  • The Towers of Hanoi puzzle asks you to move a pile of disks from tower A to tower C, one disk at a time, without ever putting a disk on a smaller one.
  • Every pile (a tree) contains smaller piles (subtrees). To move n disks, move the top n − 1 disks to the spare tower, move the largest disk to the destination, then move the n − 1 disks on top of it.
  • The base case is a pile of one disk, which moves directly. The towers trade the roles of source, intermediate and destination from one call to the next.
  • The solution takes 2ⁿ − 1 moves, because moves(n) = 2 × moves(n − 1) + 1. Each extra disk doubles the work.
  • Merging combines two sorted arrays into one sorted array by repeatedly copying the smaller front item. Both inputs must already be sorted.
  • Mergesort splits an array in half, sorts each half recursively, and merges the halves. A subarray of one item is the base case.
  • Mergesort runs in O(N log N) time. There are log₂N levels of merging, and each level copies all N items into the workspace and back.
  • Its main drawback is that it needs a workspace array as large as the array being sorted.

Q&A

Is the Towers of Hanoi good for anything besides teaching?
Not much in everyday programming. Its value is as a demonstration. A puzzle that seems to need a clever strategy turns out to have a three-step solution once you look at it recursively. It is also a vivid reminder that some problems take exponential time however you solve them.
Could I solve the puzzle without recursion?
Yes. As Hour 11 explained, any recursive algorithm can be rewritten to keep its own stack in place of the chain of function calls. The recursive version is the clearest way to express the idea.
Why not always use mergesort, since it beats the simple sorts so badly?
It needs extra memory as large as the array itself, which can be a problem with very large data. Quicksort, the subject of the next two hours, is usually faster in practice and sorts in place. And for very small arrays a simple insertion sort can win, because it has almost no overhead.
Does mergesort care whether the data starts out sorted?
Hardly at all. For a given N it always makes the same number of copies, and its comparisons can only vary between the minimum and maximum figures in the table. Insertion sort is quite different. It is very fast on almost-sorted data and very slow on inversely sorted data.

Quiz

  1. How many moves does the Towers of Hanoi take with 5 disks?

    Answer

    31. The formula 2ⁿ − 1 gives 2⁵ − 1 = 31, and so does doubling the four-disk answer and adding one (2 × 15 + 1).

  2. What are the three steps for moving a pile of n disks from tower S to tower D?

    Answer

    Move the top n − 1 disks from S to the intermediate tower I. Move the largest disk from S to D. Move the n − 1 disks from I to D.

  3. What is the base case in doTowers(), and what does the function do there?

    Answer

    The base case is a pile of one disk (topN == 1). The function prints the move of disk 1 from the source tower to the destination tower and returns.

  4. What must be true of two arrays before you can merge them?

    Answer

    Each must already be sorted, in the same order. Merging relies on the smallest remaining item of each array always being at its front.

  5. How much extra memory does mergesort need?

    Answer

    A workspace array the same size as the array being sorted, so memory use roughly doubles.

  6. What is the Big O efficiency of mergesort, and how many copies does it make when N is 64?

    Answer

    O(N log N). With N = 64 there are log₂64 = 6 levels, so it makes 64 × 6 = 384 copies into the workspace and 768 copies in total.

13

Part III · Recursion and Quicksort

Hour 13 Quicksort

Quicksort is the sort most programmers reach for, because in most situations it is the fastest. It is built on a simpler operation called partitioning, so this hour masters partitioning first and then turns it into a complete recursive sort.

In this hour you'll learn

  • What partitioning is, and how the Partition Workshop applet shows it
  • How the two-pointer partition algorithm works, in concept and in C++
  • Why partitioning takes O(N) time
  • How quicksort combines partitioning with recursion
  • Why the rightmost item makes a handy pivot, and how the pivot lands in its final place
  • How the basic quicksort's C++ code and the quickSort1 applet work

Quicksort is the most popular sorting algorithm, and with good reason. In most situations it is the fastest way to sort data held in memory, running in O(N log N) time. It was invented by C.A.R. (Tony) Hoare in 1962.

The whole algorithm rests on one simpler operation, partitioning. Once partitioning makes sense, quicksort itself takes only a few more lines, so that is where this hour begins.

Partitioning

To partition data is to divide it into two groups. Every item with a key larger than some chosen value goes into one group, and every item with a smaller key goes into the other.

People partition things all the time without using the word. A company might split its personnel records into employees who live within 15 miles of the office and those who live farther away, perhaps to organize car pools. A school might divide its students into those with grade point averages above 3.5 and those below, to see who qualifies for an honors program.

The Partition Workshop Applet

The Partition Workshop applet starts with 12 bars of random heights, and it can also show 100. A horizontal line across the bars marks the pivot value. Two arrows labeled leftPtr and rightPtr start at the two ends of the row and work toward each other as you press Step or Run.

When the applet finishes, the row falls into two groups. Every bar to the left of the dividing point is shorter than the pivot line, and every bar to the right is taller.

149411887213017961635811134177pivot
Before partitioning. Twelve bars in random order, with the pivot value 99 drawn as a dashed line.
344158729617130163188111149177pivotpartition
After partitioning. The six bars shorter than the line fill cells 0 to 5, and the six taller bars (highlighted) start at cell 6, the partition.

Look closely at the result and you'll see that partitioning is a long way from sorting. Inside each group the bars are still in no particular order. For example, 96 comes before 17 on the left, and 188 comes before 111 on the right.

Partitioning isn't stable either. Swaps can carry an item a long way across the array, so two items with equal keys may end up in the opposite order from the one they started in.

Notice also where the dividing point falls. The applet picks its pivot value fairly arbitrarily, so the dividing point is rarely in the middle of the array. In this example it happens to land exactly halfway, six bars and six bars, but that is luck. A pivot value near the top of the range would put most of the bars in the left group.

The re-creation below partitions the bars the same way. It draws the pivot line and the two pointers, explains each step in a message line, and counts comparisons and swaps.

The Partition Algorithm

The algorithm uses two pointers that start at opposite ends of the array and move toward each other. They are array indexes, not C++ pointers. leftPtr moves to the right, and rightPtr moves to the left.

leftPtr keeps moving as long as it finds items smaller than the pivot, since those are already in the correct group. It stops at the first item that is larger, because that item belongs in the right-hand group. rightPtr does the mirror-image job. It moves left past items larger than the pivot and stops at the first smaller one.

When both pointers have stopped, each one is resting on an item that sits on the wrong side, so the algorithm swaps the two items. That one swap fixes both of them. The pointers then set off again, and the stop-and-swap cycle repeats until the pointers meet or cross. At that moment the partition is complete, and leftPtr marks the first item of the right-hand group.

Picture tidying a bookshelf so that the paperbacks end up on the left and the hardcovers on the right. Your left hand walks in from the left end until it touches a hardcover, and your right hand walks in from the right end until it touches a paperback. You swap those two books and keep going. When your hands meet, the shelf is divided.

344118872130179616358111149177pivotleftPtrrightPtr
The second round of the example. leftPtr has stopped at 188, which is taller than the pivot line, and rightPtr has stopped at 58, which is shorter. Both are on the wrong side, so they are about to be swapped.

Here is the complete partition of the twelve bars from the figures, one round per row.

Partitioning the twelve bars around the pivot value 99
RoundleftPtr stops atrightPtr stops atAction
1cell 0 (149)cell 10 (34)swap 149 and 34
2cell 2 (188)cell 8 (58)swap 188 and 58
3cell 4 (130)cell 6 (96)swap 130 and 96
4cell 6 (130)cell 5 (17)pointers have crossed, so stop

In round 4, leftPtr moves past 17 and stops at 130 in cell 6. rightPtr moves one cell, onto 17 in cell 5, and stops there. The pointers have crossed, so the work is done. The partition is at cell 6, the first cell of the right-hand group.

One situation needs special care. If every item happens to be smaller than the pivot, leftPtr never finds a reason to stop, and it would run right off the end of the array into memory that doesn't belong to it. The mirror case, with every item larger than the pivot, sends rightPtr off the other end. So each scan also checks that its pointer is still inside the range.

C++ Code for Partitioning

The partition.cpp program keeps its data in a class called ArrayPar, built much like DArray from Hour 12. It stores the items in a vector<double> and adds two members that matter here, a swap() helper and the partitionIt() function.

partition.cpp The ArrayPar class and its partitionIt() function
// partition.cpp
// Partitions an array around a pivot value.
#include <iostream>
#include <vector>
#include <cstdlib>
#include <ctime>
using namespace std;

class ArrayPar
{
private:
   vector<double> theVect;
   int nElems;
public:
   ArrayPar(int max) : nElems(0) { theVect.resize(max); }
   void insert(double value) { theVect[nElems++] = value; }
   int size() { return nElems; }
   void display();
   int partitionIt(int left, int right, double pivot);

   // Exchange the items in two cells.
   void swap(int dex1, int dex2)
   {
      double temp = theVect[dex1];
      theVect[dex1] = theVect[dex2];
      theVect[dex2] = temp;
   }
};

// Rearrange cells left..right so that items smaller than the pivot
// come first. Returns the index where the larger items begin.
int ArrayPar::partitionIt(int left, int right, double pivot)
{
   // Each pointer starts one step outside the range, because it
   // always moves before it looks at an item.
   int leftPtr = left - 1;
   int rightPtr = right + 1;

   while(true)
   {
      // Step right past small items. Stop at an item that belongs
      // in the right-hand group, or when the range runs out.
      do
         ++leftPtr;
      while(leftPtr <= right && theVect[leftPtr] < pivot);

      // Step left past large items. Stop at an item that belongs
      // in the left-hand group, or when the range runs out.
      do
         --rightPtr;
      while(rightPtr >= left && theVect[rightPtr] > pivot);

      // Once the pointers meet or cross, every item is in its group.
      if(leftPtr >= rightPtr)
         break;

      // Both items are on the wrong side, so they trade places.
      swap(leftPtr, rightPtr);
   }
   return leftPtr;
}

partitionIt() takes the bounds of the range to partition and the pivot value, and it returns the index where the right-hand group begins. The outer while(true) loop repeats the stop-and-swap cycle. Inside it, the first do loop moves leftPtr and the second moves rightPtr. Once both have stopped, the if checks whether they have met or crossed; if they have, break ends the loop, and if not, the two items are swapped and the cycle starts again.

This code is more delicate than it looks. Each pointer steps first and looks second, so the pointers start one cell outside the range, at left - 1 and right + 1, and their first steps land on the two end cells. Stepping first also guarantees progress. After a swap, the two items under the pointers are on their correct sides, and the next round moves past them instead of examining them again.

The loop conditions also settle what happens to items equal to the pivot. Neither scan moves past one, so an equal item stops both pointers and gets swapped. That looks like wasted effort, yet it pays off when many keys are the same.

With an array of ten identical keys, the pointers meet in the middle after five swaps and split the array into two equal halves. If the scans skipped equal keys instead, leftPtr would run all the way to the end and the split would be as lopsided as possible.

main() fills an ArrayPar with 16 random numbers between 0 and 199 and displays them. It then partitions the whole array around the pivot value 99, reports where the partition fell, and displays the array again. Because the data is random, the partition index changes from run to run. Here is one run.

41 67 134 100 169 124 78 158 162 64 105 145 81 27 161 91
Pivot is 99, Partition is at index 7
41 67 91 27 81 64 78 158 162 124 105 145 169 100 161 134

Seven of the sixteen numbers were below 99, so they fill cells 0 through 6, and the larger group starts at index 7. Within each group the numbers are still jumbled.

Efficiency of the Partition Algorithm

Partitioning runs in O(N) time. The two pointers start at opposite ends and move toward each other one cell at a time, and they stop when they meet. Between them they cover the array once, so the work grows in step with N.

Comparisons are easy to count. Every item is compared with the pivot, and where the pointers cross, a cell or two gets compared twice. That makes about N + 2 comparisons. In the twelve-bar example, leftPtr made 7 comparisons and rightPtr made 7, for 14 in all.

Swaps depend on how the data is arranged, and there are always fewer swaps than comparisons. The worst arrangement is inverse order, where every item in the left half belongs on the right and every item in the right half belongs on the left. Then every pair is swapped, which comes to N/2 swaps.

Random data needs fewer. With a pivot near the middle value, only about half the items in each half are on the wrong side, so there are roughly N/4 swaps. The twelve-bar example needed just 3.

Basic Quicksort

A partition leaves an array divided into two groups, each still unsorted. Quicksort finishes the job with recursion.

The Quicksort Algorithm

The basic quicksort has three steps.

  1. Partition the array or subarray into a left group with smaller keys and a right group with larger keys.
  2. Call itself to sort the left group.
  3. Call itself to sort the right group.

The base case is a subarray with one item, or none. Such a subarray is already sorted, so the function returns without doing anything.

Those three steps are enough because, after a partition, every key in the left group is smaller than every key in the right group. No item will ever need to cross from one group to the other, so each group can be sorted on its own. When both groups are sorted, the whole array is sorted, with no merging needed at the end.

Compare this with mergesort. Mergesort splits the array without looking at the data and does its real work afterward, when it merges. Quicksort does its real work first, when it partitions, and has nothing left to do once its recursive calls return.

Choosing a Pivot Value

Quicksort still needs a rule for choosing the pivot value. Lafore's answer is to use the key of an actual item in the subarray as the pivot value. That item is called the pivot. It can be chosen more or less at random, and the simplest choice is the rightmost item of the subarray.

While the partition runs, the pivot stays put at the right end. partitionIt() divides the other items, the ones from left to right - 1, into the usual two groups.

When the partition finishes, the pivot is still at the far right, beyond the larger group, though it belongs between the two groups. One way to get it there would be to shift the whole right-hand group one cell to the right, opening a gap, but that could mean moving nearly N items.

A single swap does the job instead. Exchange the pivot with the item at leftPtr, the leftmost item of the right-hand group. That item moves to the far right end, which is still part of the right-hand group. The right-hand group was in no particular order anyway, so nothing is lost.

42896312942778350711855pivot
Before. The rightmost bar, 55 (highlighted), is the pivot, and its height sets the dashed line.
42185012327559463718978pivot
After partitioning and the final swap. The pivot has landed in cell 6, its sorted position, with six smaller bars to its left and five larger bars to its right.

After that swap, the pivot has every smaller key to its left and every larger key to its right. It is therefore in its final sorted position, and it will never move again. The recursive calls leave it out and sort only the ranges on either side of it.

The rightmost pivot has one more benefit. It sits at the end of the range that leftPtr scans toward, and leftPtr stops at any item that isn't smaller than the pivot, including the pivot itself. So leftPtr can never run past the pivot, and the range test on that scan can be dropped. A value placed at the end of a range to stop a scan like this is called a sentinel.

C++ Code for Quicksort

The quickSort1.cpp program uses a class named ArrayIns, which stores its data the same way as ArrayPar. Its public quickSort() function calls recQuickSort(0, nElems - 1) to sort the whole array. These are the two functions that do the work.

quickSort1.cpp Quicksort with the rightmost item as the pivot
void ArrayIns::recQuickSort(int left, int right)
{
   // Base case: a range of one item (or none) is already sorted.
   if(right - left <= 0)
      return;

   // Use the rightmost item as the pivot and split the range.
   double pivot = theVect[right];
   int partition = partitionIt(left, right, pivot);

   // The pivot is now in its final place. Sort each side of it.
   recQuickSort(left, partition - 1);
   recQuickSort(partition + 1, right);
}

int ArrayIns::partitionIt(int left, int right, double pivot)
{
   // The pivot waits in cell right, so the scan covers left..right-1.
   int leftPtr = left - 1;
   int rightPtr = right;

   while(true)
   {
      // No range test is needed here. At the latest, leftPtr stops at
      // the pivot itself, because the pivot is not smaller than itself.
      do
         ++leftPtr;
      while(theVect[leftPtr] < pivot);

      // rightPtr has no such stopper, so it checks the range.
      do
         --rightPtr;
      while(rightPtr > left && theVect[rightPtr] > pivot);

      if(leftPtr >= rightPtr)
         break;
      swap(leftPtr, rightPtr);
   }
   // Put the pivot between the two groups, where it belongs for good.
   swap(leftPtr, right);
   return leftPtr;
}

recQuickSort() first checks the size of its range. If right - left is zero or less, the range holds one item or none, and the function returns. Otherwise it takes the rightmost item as the pivot, partitions the range, and calls itself twice, once for each side of the pivot's new home.

This partitionIt() is the one from partition.cpp with three changes. rightPtr starts at right instead of right + 1, so its first step lands on right - 1 and the pivot is never examined. The leftPtr scan has no range test, because the pivot is its sentinel. And after the loop, one more swap() moves the pivot from cell right to cell leftPtr, whose index the function returns.

Here is how the program sorts the twelve bars from the figure. The first call partitions cells 0 to 11 around 55, which lands in cell 6. The call for the left side partitions cells 0 to 5 around 27, which lands in cell 3, and then cells 0 to 2 are partitioned around 12. Only when everything left of cell 6 is sorted does the program turn to cells 7 to 11.

cells 0–11pivot 55 → cell 6cells 0–5pivot 27 → cell 3cells 7–11pivot 78 → cell 9cells 0–2pivot 12 → cell 1cells 4–5pivot 50 → cell 5cells 7–8pivot 63 → cell 7cells 10–11pivot 94 → cell 11
The partitions made while sorting the twelve bars. Each box shows the range being partitioned, the pivot value, and the cell where the pivot ends up. Ranges of one item, or none, return at once and are not drawn.

Seven partitions sort the twelve items. The other calls all get a range of one item or none, and they return at once. main() fills the array with 16 random numbers from 0 to 99, displays them, sorts them, and displays them again. One run produced this output.

41 67 34 0 69 24 78 58 62 64 5 45 81 27 61 91
0 5 24 27 34 41 45 58 61 62 64 67 69 78 81 91

The quickSort1 Workshop Applet

The quickSort1 Workshop applet runs this algorithm on the bars. With 100 bars, pressing Run gives the big picture. The array is divided into ranges, and the ranges are divided again, into smaller and smaller pieces, until everything is in order.

With 12 bars, pressing Step shows every detail of each partition. The applet marks the range being worked on, highlights the pivot (the rightmost bar of the range), and draws the pivot line across the bars. You watch leftPtr and rightPtr stop and swap, and at the end you see the pivot swapped into its final place between the two groups. The re-creation below shows the same things, with a message for every step.

A First Look at Efficiency

Quicksort runs in O(N log N) time on average, and the call tree above shows why. The ranges on each level of the tree are separate, and together they cover nearly the whole array. A partition is O(N), so each level costs about N steps. The number of levels depends on the pivots.

If the pivots split their ranges roughly in half, the ranges shrink from N items to N/2, then N/4, N/8 and so on, and they reach single items after about log₂N levels. About N steps on each of about log₂N levels gives N × log₂N in all. The twelve items above needed three levels of partitioning, close to log₂12, which is about 3.6.

That reasoning assumes the pivots behave. The rightmost item works well for random data, but some arrangements of data make it a terrible choice, and quicksort then slows to a crawl. Hour 14 shows what goes wrong and how to fix it.

Summary

  • Partitioning divides data into two groups around a pivot value, with smaller keys in one group and larger keys in the other.
  • The partition algorithm moves leftPtr and rightPtr toward each other from the two ends. Each stops at an item on the wrong side, the two items are swapped, and the process repeats until the pointers cross.
  • Partitioning leaves each group unsorted, and it isn't stable.
  • Partitioning takes O(N) time, with about N + 2 comparisons and at most about N/2 swaps.
  • Quicksort, invented by C.A.R. Hoare in 1962, partitions a range and then calls itself to sort the left group and the right group. A range of one item or none is the base case.
  • quickSort1 uses the rightmost item as the pivot. After partitioning the other items, it swaps the pivot to the boundary between the groups, which is its final sorted position.
  • A pivot at the right end is a sentinel for leftPtr, so that scan needs no range test.
  • Quicksort averages O(N log N). Each level of recursion partitions about N items, and pivots that split ranges near the middle give about log₂N levels.

Q&A

Why is it called quicksort if it can be slow?
In most situations it is the fastest sort available. The slow case needs particular arrangements of data, such as data that is already sorted, and Hour 14 shows how to make that case extremely unlikely.
Does the pivot have to be the rightmost item?
No. Any item in the range would work. The rightmost one is convenient because it can stay out of the way during the partition, and it doubles as a sentinel. Hour 14 replaces it with a smarter choice.
Why does partitionIt() stop at items equal to the pivot instead of skipping them?
Skipping them would make partitions badly unbalanced when the data holds many equal keys. By stopping and swapping, the pointers meet near the middle of a run of equal keys and split it evenly.
Why doesn't quicksort need a workspace array like mergesort's?
It rearranges items by swapping them within the array itself, and it has no merge step at the end. The only extra memory it uses is the stack space for its recursive calls.

Quiz

  1. What does partitioning do to an array?

    Answer

    It rearranges the items into two groups around a pivot value, with the smaller keys on the left and the larger keys on the right. The items within each group stay in no particular order.

  2. Why does partitioning take O(N) time?

    Answer

    The two pointers start at opposite ends and move toward each other until they meet, so each item is compared with the pivot about once. That is about N + 2 comparisons and at most about N/2 swaps.

  3. In quickSort1, why is the pivot in its final sorted position once partitionIt() returns?

    Answer

    The final swap puts it at the boundary between the groups. Every item to its left is smaller and every item to its right is larger, and later calls sort only the two sides, so nothing will ever move it.

  4. What is the base case of recQuickSort()?

    Answer

    A range that holds one item or none, which the code detects with right - left <= 0. Such a range is already sorted, so the function returns at once.

  5. Who invented quicksort, and when?

    Answer

    C.A.R. (Tony) Hoare, in 1962.

  6. Is partitioning stable?

    Answer

    No. Swaps can move items long distances, so two items with equal keys may end up in the opposite order from the one they started in.

14

Part III · Recursion and Quicksort

Hour 14 Improving Quicksort

The basic quicksort of Hour 13 slows to a crawl on data that is already in order. This hour fixes that with a smarter pivot, speeds up the smallest subarrays, and sums up how fast quicksort really is.

In this hour you'll learn

  • Why sorted and inversely sorted data drag the basic quicksort down to O(N²)
  • How median-of-three partitioning picks a better pivot, and how its C++ code works
  • How the quickSort2 applet compares with quickSort1 on inversely sorted data
  • Two ways to hand small partitions to an insertion sort
  • Why quicksort runs in O(N log N) time on average, and how it compares with the other sorts

Problems with Inversely Sorted Data

If you tried the last step of the quickSort1 exercise in Hour 13, you have already seen the problem. Given inversely sorted bars, the applet grinds through the sort far more slowly than it does with random ones. Data that is already sorted the normal way is just as bad.

The trouble lies in the pivot. A partition works best when the pivot value is the median of the items, the value with half the items above it and half below, because then the range splits into two equal halves. The further the pivot is from the median, the more lopsided the split. With inversely sorted data, the rightmost item is as far from the median as it can possibly be, since it is the smallest item in the range.

120110100908070605040302010pivot
Inversely sorted bars. The pivot is the rightmost bar, 10, the smallest of all, so the pivot line runs below every other bar. The left group will be empty, and the other eleven bars all land in the right group.

A pivot like that divides almost nothing. The left group is empty, the pivot takes one cell, and the other N − 1 items all end up in the right group. Picture splitting a class into students shorter and taller than a chosen classmate, and always choosing the shortest one. Everybody else lands in the taller group, and you have made almost no progress.

The next partition runs into the same trouble. With inversely sorted data, the swap that puts each pivot in place moves the largest item to the right end, so the pivots alternate between the smallest and the largest item left in the range. Either way, one group comes out empty every time. With data that is already sorted, the rightmost item is always the largest, and the result is the same.

6 items · pivot 10 (smallest)0 items5 items · pivot 60 (largest)0 items4 items · pivot 20 (smallest)0 items3 items · pivot 50 (largest)0 items2 items · pivot 30 (smallest)0 items1 item · base case
Quicksort with the rightmost pivot on six inversely sorted items, 60 50 40 30 20 10. Each partition fixes a single item and leaves one group empty (the dashed boxes), so six items need six levels of recursion.

Instead of about log₂N levels of recursion there are now N levels, and each level partitions just one item fewer than the level above it. The comparisons add up to about N + (N − 1) + (N − 2) + … + 1, which is roughly N²/2. The twelve inversely sorted bars in the first figure cost 76 comparisons, a little more than 12²/2 = 72. Quicksort has turned into an O(N²) sort, no better than the bubble sort of Hour 4.

There is a second danger. Every recursive call occupies space on the machine's call stack until it returns. With N levels of recursion, a large array can use up all of that space, and the program then crashes with a stack overflow instead of merely running slowly.

Median-of-Three Partitioning

People have proposed many ways to choose a better pivot. The ideal would be the true median of the subarray, since it always splits the range in half. Finding the true median, though, means examining every item, which takes longer than the sort can afford at every step.

The usual compromise is to look at just three items, the first, the middle and the last, and use the median of those three as the pivot. This is called median-of-three partitioning. Putting three items in order takes at most three comparisons, so choosing the pivot stays quick.

For sorted and inversely sorted data, the middle item is the true median of the range, so median-of-three picks an ideal pivot and the partitions stay well balanced. For random data, the median of three items is unlikely to be the smallest or largest item in the range. The worst case is still possible in principle, but it takes an unusual arrangement of data to trigger it.

Take the sorted numbers 10, 20, 30 and so on up to 90. The first, middle and last items are 10, 50 and 90, and their median, 50, is also the median of all nine numbers. The range splits into four items on each side of the pivot, as evenly as it possibly can.

920351662123174815496287538leftcenterright
Before. Median-of-three examines the left, center and right items of the subarray, here 92, 17 and 53.
170351662123284815496537928leftpivotright
After. The three are in order, with 17 at the left and 92 at the right, and the median, 53, has been swapped into cell 7, next to the right end, to serve as the pivot.

Sorting the three sample items brings two bonuses. The first is speed in the inner loops. After the sort, the left item is no larger than the pivot, and the pivot itself sits just inside the right end.

The left item therefore stops rightPtr, and the pivot stops leftPtr. Both act as sentinels, so neither scan needs a range test. The inner loops run for every item on every level of recursion, so removing a test from them pays off.

The second bonus is less work. The left and right items are already on their correct sides, so the partition can skip them. The median is parked in cell right - 1, out of the way, and the partition deals only with cells left + 1 through right - 2. When the pointers cross, the pivot is swapped from right - 1 into its final place, just as before.

In the example, the partition covers cells 1 through 6 with the pivot 53. In the first round, leftPtr stops at 66 in cell 2 and rightPtr stops at 49 in cell 6, and the two are swapped. In the second round, leftPtr stops at 81 in cell 5 and rightPtr at 28 in cell 4, so the pointers have crossed. Swapping the pivot into cell 5 gives 17 35 49 12 28 53 66 81 92, with 53 in its sorted position.

C++ Code for Median-of-Three

The quickSort2.cpp program keeps the ArrayIns class of quickSort1.cpp and rewrites its private functions. recQuickSort() now gets its pivot from medianOf3(), and it passes small ranges to a new function, manualSort().

quickSort2.cpp The recursive sort and the median-of-three pivot
void ArrayIns::recQuickSort(int left, int right)
{
   int size = right - left + 1;

   // Median-of-three needs at least four items to work with.
   if(size <= 3)
   {
      manualSort(left, right);
      return;
   }
   double median = medianOf3(left, right);
   int partition = partitionIt(left, right, median);
   recQuickSort(left, partition - 1);
   recQuickSort(partition + 1, right);
}

double ArrayIns::medianOf3(int left, int right)
{
   int center = (left + right) / 2;

   // Put the three sample items in order, so that
   // theVect[left] <= theVect[center] <= theVect[right].
   if(theVect[left] > theVect[center])
      swap(left, center);
   if(theVect[left] > theVect[right])
      swap(left, right);
   if(theVect[center] > theVect[right])
      swap(center, right);

   // Park the median next to the right end and hand back its value.
   swap(center, right - 1);
   return theVect[right - 1];
}

recQuickSort() starts by measuring its range, because the median-of-three method needs at least four items. With three, ordering the left, center and right items already sorts the whole range and leaves nothing to partition, and with two, the three sample positions overlap and the sentinels are lost. So a range of three or fewer items goes to manualSort(), which sorts it directly. Larger ranges get a median-of-three pivot, a partition and two recursive calls, as before.

medianOf3() finds the middle of the range and then makes three compare-and-swap steps. The first two leave the smallest of the three items in the left cell, and the third puts the center and right cells in order. The function then swaps the median into cell right - 1 and returns its value, which becomes the pivot.

Traced on the example from the figures, the left, center and right items start as 92, 17 and 53. The first step swaps 92 and 17, the second finds 17 and 53 already in order, and the third swaps 92 and 53, leaving 17, 53 and 92. The last swap moves 53 from cell 4 into cell 7 and sends the 28 that was there back to cell 4.

quickSort2.cpp Partitioning with sentinels, and sorting tiny ranges by hand
int ArrayIns::partitionIt(int left, int right, double pivot)
{
   // Cells left and right are already on the correct sides, and the
   // pivot waits in cell right-1. Only left+1..right-2 is scanned.
   int leftPtr = left;
   int rightPtr = right - 1;

   while(true)
   {
      // No range tests: the pivot in cell right-1 stops leftPtr, and
      // the small item in cell left stops rightPtr.
      do
         ++leftPtr;
      while(theVect[leftPtr] < pivot);
      do
         --rightPtr;
      while(theVect[rightPtr] > pivot);

      if(leftPtr >= rightPtr)
         break;
      swap(leftPtr, rightPtr);
   }
   // Move the pivot from right-1 to the boundary between the groups.
   swap(leftPtr, right - 1);
   return leftPtr;
}

void ArrayIns::manualSort(int left, int right)
{
   int size = right - left + 1;

   // One item (or none) needs no work.
   if(size <= 1)
      return;

   // Two items: one comparison, and perhaps one swap.
   if(size == 2)
   {
      if(theVect[left] > theVect[right])
         swap(left, right);
      return;
   }

   // Three items: order the first two, then pull the smallest of all
   // three to the front, then order the last two.
   int middle = left + 1;
   if(theVect[left] > theVect[middle])
      swap(left, middle);
   if(theVect[left] > theVect[right])
      swap(left, right);
   if(theVect[middle] > theVect[right])
      swap(middle, right);
}

This partitionIt() is shorter than the one in quickSort1.cpp. leftPtr starts at left, so its first step lands on left + 1. rightPtr starts at right - 1, the pivot's cell, so its first step lands on right - 2. Neither scan tests the range, because the sentinels guarantee that both will stop, and the final swap uses right - 1, where the pivot was parked.

manualSort() treats the three small sizes separately. One item needs no work. Two items need one comparison and perhaps a swap. Three items need the same three compare-and-swap steps that medianOf3() uses, which is enough to put any three items in order.

main() is the same as in quickSort1.cpp. It fills the array with 16 random numbers, displays them, sorts them and displays them again. Here is one run.

48 96 94 91 31 77 2 3 17 68 9 33 23 52 78 99
2 3 9 17 23 31 33 48 52 68 77 78 91 94 96 99

The quickSort2 Workshop Applet

The quickSort2 Workshop applet uses median-of-three partitioning, and like manualSort() it sorts ranges of three or fewer items directly. Its display looks like the quickSort1 applet's. The lesson comes from running the two applets on the same kind of data.

Give quickSort2 inversely sorted bars and it stays fast. Each median-of-three pivot splits its range near the middle, the ranges shrink quickly, and the sort finishes in a small fraction of the steps quickSort1 needs. With random data, the two applets perform much more alike.

The re-creation below puts both algorithms in one applet, so you can compare them side by side. It starts with inversely sorted bars.

Handling Small Partitions

Median-of-three already forced one special case, manualSort() for ranges of three items or fewer. The same idea can be pushed further. Quicksort is built for big arrays, and on a tiny subarray its machinery costs more than it saves. Every little range still pays for a function call, a median-of-three and a partition, while a simple sort could put a handful of items in order with far less fuss.

The waste adds up because the recursion is widest at the bottom. Each level holds about twice as many ranges as the level above it, so most of quicksort's calls are made on ranges of just a few items.

The insertion sort of Hour 5 is the natural helper. It is O(N²), but for a few items that hardly matters, and its overhead is tiny. There are two ways to combine it with quicksort.

Using an Insertion Sort for Small Partitions

The first way sets a cutoff. Any subarray smaller than the cutoff is sorted with an insertion sort instead of being partitioned any further. Lafore's quickSort3.cpp uses a cutoff of 10, and Donald Knuth recommends a cutoff of about 9. The best value depends on your computer, your compiler and your data, so it pays to experiment.

quickSort3.cpp Handing small ranges to an insertion sort
void ArrayIns::recQuickSort(int left, int right)
{
   int size = right - left + 1;

   // Below the cutoff, an insertion sort is cheaper than partitioning.
   if(size < 10)
      insertionSort(left, right);
   else
   {
      double median = medianOf3(left, right);
      int partition = partitionIt(left, right, median);
      recQuickSort(left, partition - 1);
      recQuickSort(partition + 1, right);
   }
}

// An insertion sort that works only on the cells left..right.
void ArrayIns::insertionSort(int left, int right)
{
   for(int out = left + 1; out <= right; out++)
   {
      // Lift out the next item, leaving a gap behind it.
      double temp = theVect[out];
      int in = out;

      // Shift larger items one cell to the right until the gap
      // reaches the place where temp belongs.
      while(in > left && theVect[in - 1] > temp)
      {
         theVect[in] = theVect[in - 1];
         --in;
      }
      theVect[in] = temp;
   }
}

The new recQuickSort() looks like the quickSort2 version, except that it compares the size with the cutoff. Every range that reaches medianOf3() now holds at least ten items, so manualSort() is no longer needed.

insertionSort() is the insertion sort from Hour 5, adapted to work on the cells from left to right instead of the whole array. The outer loop takes each item in turn and holds it in temp. The inner loop shifts larger items one cell to the right until it finds the gap where temp belongs, stopping at left at the latest.

Insertion Sort Following Quicksort

The second way turns the idea around. Quicksort ignores every subarray smaller than the cutoff and leaves it unsorted. When the recursion is finished, the array is made of small blocks. Each block holds exactly the items that belong in it, in the right place relative to the other blocks, only jumbled among themselves.

A single insertion sort over the whole array then finishes the job. As Hour 5 showed, insertion sort is very fast on almost-sorted data, because no item has far to travel. Here no item sits more than a block's length from its final cell, so this last pass takes roughly linear time.

quickSort3.cpp (variant) Leaving small ranges for one final insertion sort
void ArrayIns::quickSort()
{
   recQuickSort(0, nElems - 1);

   // One pass puts the items inside every small block in order.
   insertionSort(0, nElems - 1);
}

void ArrayIns::recQuickSort(int left, int right)
{
   // Leave small ranges unsorted for the final insertion sort.
   if(right - left + 1 < 10)
      return;

   double median = medianOf3(left, right);
   int partition = partitionIt(left, right, median);
   recQuickSort(left, partition - 1);
   recQuickSort(partition + 1, right);
}

Both approaches give a modest speedup, and neither one changes quicksort's Big O. The size of the gain depends on the system, anywhere from a few percent to a few tens of percent.

Efficiency of Quicksort

Quicksort runs in O(N log N) time on average. Its worst case is O(N²), but with median-of-three pivots that case becomes rare.

The reason for N log N is the one sketched in Hour 13. The ranges on each level of recursion are separate and together hold about N items, and partitioning is linear, so each level costs about N steps. When the pivots split ranges near the middle, the ranges halve from one level to the next. There is one range of N items, then two of N/2, then four of N/4, and so on, down to ranges of a single item after about log₂N levels.

NN/2N/2N/4N/4N/4N/4… and so on, down to ranges of 1 item1 range: N items2 ranges: N items4 ranges: N itemsabout log₂N levels
Why quicksort is O(N log N). Every level of recursion partitions N items in total, and when the pivots split ranges near the middle, the ranges shrink to single items after about log₂N levels.

About N steps on each of about log₂N levels gives N × log₂N steps. For a million items log₂N is about 20, so quicksort needs something like 20 million steps, where an O(N²) sort would need about a trillion.

Quicksort and the Other Sorts

Mergesort is also O(N log N), yet quicksort usually beats it in practice, for two reasons. First, quicksort sorts in place. It needs no workspace array and none of the copying into and out of one. Second, its inner loops are extremely tight, since each step of a scan is little more than one comparison and one increment.

Mergesort keeps one advantage of its own. Its running time is guaranteed, because it never degrades to O(N²) whatever the data, while quicksort's speed depends on getting reasonable pivots.

The sorts of Hours 4 to 14 compared
SortAverageWorst caseExtra memoryNotes
Bubble sortO(N²)O(N²)NoneSimplest, and usually the slowest
Insertion sortO(N²)O(N²)NoneVery fast on almost-sorted data
MergesortO(N log N)O(N log N)A workspace of N itemsSpeed hardly depends on the data
QuicksortO(N log N)O(N²), rare with median-of-threeNone beyond the recursion stackUsually the fastest in practice

Hour 24 returns to these trade-offs when it sums up which structures and algorithms to use when.

Summary

  • With the rightmost item as the pivot, sorted and inversely sorted data make every partition lopsided, with one group of N − 1 items and one empty group.
  • Lopsided partitions mean N levels of recursion instead of about log₂N, roughly N²/2 comparisons, O(N²) time, and a risk of overflowing the stack.
  • The ideal pivot is the median, but finding it takes too long. Median-of-three uses the median of the first, middle and last items instead.
  • Median-of-three keeps sorted and inversely sorted data balanced and makes the worst case rare for any data.
  • Ordering the three sample items puts sentinels at both ends, so the inner loops need no range tests, and the partition only has to cover cells left + 1 to right − 2.
  • Median-of-three needs at least four items, so quickSort2 sorts ranges of three or fewer with manualSort().
  • Small subarrays can be finished by an insertion sort, either one at a time below a cutoff (10 in the book, about 9 according to Knuth) or in one pass over the almost-sorted array at the end. Both give a modest speedup.
  • Quicksort averages O(N log N) and has a rare O(N²) worst case. It sorts in place and is usually faster than mergesort.

Q&A

Could I just pick the pivot at random?
Yes. A randomly chosen item is very unlikely to be the smallest or largest over and over, so it also escapes the sorted-data trap. Median-of-three has the extra benefit of setting up sentinels for the inner loops, and it doesn't depend on a random number generator.
Why does manualSort() handle exactly three items or fewer?
Median-of-three needs at least four items. With three, sorting the three sample items already sorts the whole range, and with two or one, the sample positions overlap and the sentinels disappear. Sorting such tiny ranges directly is simple and fast.
Does a cutoff change quicksort's Big O?
No. The insertion sorts work on blocks of fewer than ten items, so each item costs at most a few extra steps. The total stays proportional to N log N, and only the constant in front of it changes.
Is quicksort ever the wrong choice?
Sometimes. If a slow run can never be tolerated, mergesort's guaranteed O(N log N) is safer. If the data is known to be almost sorted already, an insertion sort alone may be quickest. And for a handful of items, any simple sort will do.

Quiz

  1. Why does inversely sorted data slow quickSort1 down to O(N²)?

    Answer

    The rightmost pivot is always the smallest or largest item in its range, so every partition leaves one group empty and the other with all the remaining items. That gives N levels of recursion instead of about log₂N, and roughly N²/2 comparisons.

  2. Which items does median-of-three examine, and which one becomes the pivot?

    Answer

    It examines the first, middle and last items of the subarray, puts them in order, and uses the middle value of the three as the pivot.

  3. Why does quickSort2 need manualSort() for subarrays of three items or fewer?

    Answer

    Median-of-three partitioning needs at least four items. With fewer, there is nothing left to partition, or the sample positions overlap and the sentinels that stop the scans are lost.

  4. What cutoff does Knuth suggest for switching to an insertion sort?

    Answer

    About 9 items. The book's quickSort3.cpp uses 10.

  5. What are the two ways to combine insertion sort with quicksort?

    Answer

    Either sort each subarray below the cutoff with an insertion sort as soon as the recursion reaches it, or leave all the small subarrays unsorted and run one insertion sort over the whole, almost-sorted array at the end.

  6. What are quicksort's average and worst-case Big O efficiencies, and how does its memory use compare with mergesort's?

    Answer

    O(N log N) on average and O(N²) in the worst case, which median-of-three makes rare. Quicksort sorts in place, apart from its recursion stack, while mergesort needs a workspace array as large as the data.

15

Part IV · Trees

Hour 15 Binary Trees

An ordered array finds items quickly but inserts them slowly, and a linked list does the reverse. A binary search tree is quick at both. This hour explains what a tree is, gives you the words for its parts, and shows how to find, insert and delete nodes in C++.

In this hour you'll learn

  • Why a tree can beat both an ordered array and a linked list
  • The vocabulary of trees: root, parent, child, leaf, subtree, level and key
  • How the Tree Workshop applet works, and how a tree is built from two C++ classes
  • How to find a node and how to insert a new one
  • How to delete a node in each of the three possible cases

Why Use Binary Trees?

Every structure so far has forced a trade. The ordered array of Hour 3 searches fast, because binary search needs only about log₂N comparisons. Inserting is slow, since every larger item must shift one cell to the right to make room, and deleting shifts them back. On average half the array moves, so both operations are O(N).

The linked list of Hours 8 to 10 has the opposite strengths. Once you are at the right spot, inserting or deleting a link means changing a pointer or two. Getting to the spot is the problem, because you can't jump into the middle of a list the way binary search jumps into an array. You walk from pFirst one link at a time, visiting half the list on average, so finding is O(N) even in a sorted list.

A tree combines the good halves of both. In a binary search tree that stays reasonably balanced, finding, inserting and deleting all take O(log N) time. For a million items that is about 20 steps per operation, where the array or the list needed hundreds of thousands. This hour and the next cover ordinary binary search trees, and Hours 17 to 20 show how to keep a tree balanced.

What Is a Tree?

A tree is a set of nodes connected by edges. The nodes usually stand for entities such as employees, car parts or airline reservations, and the edges show how those entities are related. In a C++ program an edge is a pointer. The program travels from node to node by following pointers, normally only downward, since a node keeps no pointer back up to its parent.

Computer trees are drawn upside down, with the root at the top and the leaves at the bottom, more like an organization chart than an oak. In a general tree, called a multiway tree, a node may have many children; you'll build one in Hours 19 and 20. In a binary tree each node has at most two. This hour is about one important kind of binary tree, the binary search tree.

Level 0Level 1Level 2Level 312leaf253037parent43child50root6275subtree root8793
A binary search tree with ten nodes, with some terms marked. The emphasized edges are the path from the root to 30, and the numbers at the left are the levels.

Tree Terminology

Trees have a vocabulary of their own, and you'll meet these words for the rest of the book. The figure marks several of them.

  • Path. The sequence of nodes you pass through when you walk along edges from one node to another. The path from 50 to 30 runs through 25 and 37.
  • Root. The node at the top. A tree has exactly one, and exactly one path leads from it to any other node. If two routes led to the same node, the structure wouldn't be a tree.
  • Parent. Every node except the root has one edge running up to another node, its parent. 37 is the parent of 30 and 43.
  • Child. The nodes joined to a node from below. 30 and 43 are the children of 37.
  • Leaf. A node with no children, such as 12, 30, 43, 62 and 93.
  • Subtree. Any node can be seen as the root of a smaller tree made of its children, their children, and so on down. The subtree rooted at 75 holds 75, 62, 87 and 93.
  • Visiting. Arriving at a node in order to do something with it, such as checking or displaying its key. Passing through a node on the way somewhere else doesn't count.
  • Traversing. Visiting every node in some fixed order, such as ascending order of keys. Hour 16 covers it.
  • Levels. How many generations a node is from the root. The root is on level 0, its children on level 1, their children on level 2, and so on.
  • Keys. The data field used for searching. The number drawn inside each circle is that node's key.
  • Binary tree. A tree in which every node has at most two children, called the left child and the right child. A node may have two children, one, or none.

A binary search tree adds one rule. A node's left child has a smaller key than the node, and its right child has a key greater than or equal to it. The rule holds for whole subtrees, so every key to the left of 50 is below 50 and every key to its right is 50 or more. That arrangement is what makes fast searching possible.

A Tree Analogy in Your Computer

You already use a tree every day. The directories on a disk form a hierarchy, with the root directory (such as C:\) at the top, subdirectories below it, and files at the ends of the branches. A full file name such as C:\SALES\EAST\NOVEMBER\SMITH.DAT is literally a path from the root down to one leaf. File systems borrowed the words root and path from tree theory.

Two differences stand out. A directory can hold any number of entries, so the file system is a multiway tree rather than a binary one. And directories only organize things, while the data itself lives in the files at the leaves. In a binary search tree every node, the root included, holds data.

Unbalanced Trees

A tree's shape depends on the order in which its keys arrive. Keys in random order tend to build a bushy tree, with about as many nodes on each side of every node. Keys in sorted or nearly sorted order go to the same side one after another, and the tree grows lopsided.

20304045506070
Keys that arrive almost in order (30, 20, 40, 50, 45, 60, 70) build a lopsided tree. Five of the seven nodes hang to the right of the root.

In the figure, nearly every key after 20 is larger than the ones before it. Finding 70 takes five steps, although seven nodes fit into a tree of three levels. Keys in exactly sorted order make a straight line, no faster to search than a linked list. Hour 17 starts from that worst case, and until then the speeds claimed for trees assume a reasonably balanced shape.

Basic Binary Tree Operations

The everyday jobs are finding the node with a given key, inserting a node, and deleting one. Traversing, which visits every node in order, is the topic of Hour 16. It helps to watch the operations before reading the code.

The Tree Workshop Applet

The book's Tree Workshop applet draws a binary search tree of up to five levels, which hold at most 1 + 2 + 4 + 8 + 16 = 31 nodes. Fill builds a tree with however many random nodes you ask for. Find sends a red arrow from the root down the path toward the key you typed. Ins makes the same trip and adds a node where the path runs out, Trav steps through an inorder traversal, and Del deletes a node.

Watch the arrow during a find. It visits at most one node per level, so no search in the applet takes more than five steps. With Ins, notice that the new node always appears at the bottom of a branch. Random fills also tend to leave a few branches deeper than the rest, a mild case of imbalance.

Representing the Tree in C++ Code

In C++ a tree is built from two classes. A Node holds one node's data plus a pointer to each child, and a Tree holds a pointer to the root and provides the operations.

tree.cpp The Node and Tree classes
#include <iostream>
using namespace std;

class Node
   {
   public:
      // the search key, plus one more piece of data
      int iData;
      double dData;
      // links to the children; NULL means "no child on this side"
      Node* pLeftChild;
      Node* pRightChild;

      // a new node always starts life as a leaf
      Node(int id, double dd) : iData(id), dData(dd),
                                pLeftChild(NULL), pRightChild(NULL)
         { }

      // print the node's contents, for example {43, 43.5}
      void displayNode()
         {
         cout << "{" << iData << ", " << dData << "} ";
         }
   };

class Tree
   {
   private:
      // the only node the Tree object holds directly
      Node* pRoot;
      // a helper for remove(), shown later in this hour
      Node* getSuccessor(Node* pDelNode);
   public:
      Tree() : pRoot(NULL)
         { }
      Node* find(int key);
      void insert(int id, double dd);
      bool remove(int key);
      void traverse(int traverseType);
      void displayTree();
   };

iData is the key, and dData stands in for the rest of the record, which in a real program might be a name, an address and a salary. A NULL child pointer means that child doesn't exist. The constructor sets both pointers to NULL, so every new node starts out as a leaf. The book's Node has a constructor with no arguments and fills in the data afterward; passing the data in, as here, shortens the insertion code.

The Tree class stores only pRoot. Every node can be reached by following child pointers down from the root, and an empty tree simply has a NULL root. This hour covers find(), insert() and the ideas behind remove(); Hour 16 adds traversal and display.

Finding a Node

To find a key, start at the root and compare. If the key you want is smaller than the current node's key, move to the left child. If it is larger, move to the right child, and if the two are equal, you've found it. If the child you need is missing, the key isn't in the tree, since that empty spot is the only place it could have been.

15303845576070808590
Finding 57. The search goes left at 60 (57 is smaller), right at 30 and right again at 45, visiting one node per level until it reaches 57.

A search for 50 in the same tree would take the same route as far as 57. There it would need 57's left child, find it missing, and report that 50 isn't in the tree. Here is the function.

tree.cpp Finding the node with a given key
Node* Tree::find(int key)
   {
   // begin at the top of the tree
   Node* pCurrent = pRoot;
   // go down one level per pass until we hit the key or run out of tree
   while(pCurrent != NULL && pCurrent->iData != key)
      {
      // smaller keys live on the left, larger or equal ones on the right
      if(key < pCurrent->iData)
         pCurrent = pCurrent->pLeftChild;
      else
         pCurrent = pCurrent->pRightChild;
      }
   // the matching node, or NULL if the key is not in the tree
   return pCurrent;
   }

pCurrent moves down one level per pass. The loop ends when it reaches a matching node or becomes NULL, and either way the function returns it. The book's version assumes the tree isn't empty and tests for NULL after each move; testing at the top of the loop, as here, also handles an empty tree. The caller gets a pointer, so it can display or change the node, as in if(pFound != NULL) pFound->displayNode();.

The search visits at most one node per level, and a balanced tree of N nodes has about log₂N levels, so finding is O(log N). The applet's 31-node tree never needs more than 5 comparisons, and a balanced million-node tree needs about 20. That is the same growth rate as binary search in an ordered array.

Inserting a Node

To insert a node, first find where it belongs by searching for the new key as if it were already there. The search ends by trying to move to a missing child, and that empty spot is where the new node goes, as a child of the last node visited.

304045506080
Inserting 45. The search goes left at 60, right at 40, and then needs 50's left child, which doesn't exist. The new node fills that spot as a new leaf.

A new node always becomes a leaf, and no existing node moves, so insertion never disturbs the rest of the tree. Since it follows the same path as find(), insertion is also O(log N) in a balanced tree, plus a fixed amount of work to attach the node.

There is one programming wrinkle. When the search reaches the empty spot, pCurrent is NULL and no longer tells you where you came from. So the code keeps a second pointer, pParent, one step behind. When pCurrent falls off the tree, pParent is the node that receives the new child.

tree.cpp Inserting a new node
void Tree::insert(int id, double dd)
   {
   Node* pNewNode = new Node(id, dd);
   // an empty tree: the new node becomes the root
   if(pRoot == NULL)
      {
      pRoot = pNewNode;
      return;
      }
   // walk down as find() would, keeping pParent one step behind
   Node* pParent = NULL;
   Node* pCurrent = pRoot;
   while(pCurrent != NULL)
      {
      pParent = pCurrent;
      if(id < pCurrent->iData)
         pCurrent = pCurrent->pLeftChild;
      else
         pCurrent = pCurrent->pRightChild;
      }
   // pCurrent fell off the tree; hang the new node on the last real node
   if(id < pParent->iData)
      pParent->pLeftChild = pNewNode;
   else
      pParent->pRightChild = pNewNode;
   }

An empty tree gets the new node as its root. Otherwise the loop walks down like find(), never stopping early, and the final comparison chooses pParent's left or right pointer. The book attaches the node inside the loop the moment it sees the NULL child, with the same effect. A key equal to the current node's key goes right, down the else branch; Hour 16 explains what that means for duplicate keys.

Deleting a Node

Deletion is the most complicated of the common operations. It starts with a search that records two extra facts: pParent, the parent of the node to delete, and isLeftChild, which says whether the node is its parent's left or right child. Deleting always changes one of the parent's child pointers, so you need both. When the root itself is deleted, the code changes pRoot instead.

What happens next depends on how many children the node has, which gives three cases.

Case 1: The Node Has No Children

To delete a leaf, set the parent's pointer to it, left or right according to isLeftChild, to NULL. The node is now cut off from the tree. The program also calls delete to free its memory, since C++ won't do that automatically.

1225303743delete me5062758793
Case 1. 43 is a leaf, so deleting it just sets 37's right-child pointer to NULL. No other node moves.

Case 2: The Node Has One Child

A node with one child has only that child, and the child's subtree, beneath it. Connect the node's parent straight to the child, skipping the node being deleted, and the whole subtree moves up a level. The order is preserved, because everything below the deleted node was already on the correct side of the parent.

25delete me30374350627587
Before: 25 has a single child, 37, which carries a subtree of its own.
30374350627587
After: 50 points straight to 37, and 30 and 43 have moved up with it.

The code has more variations than the idea. The only child may be on the left or the right, and the deleted node may be a left child, a right child or the root. Each time, the pointer that used to lead to the deleted node is changed to lead to its child.

Case 3: The Node Has Two Children

A node with two children can't simply be replaced by one of them. If the right child moved up, it would also have to adopt the deleted node's left child, and it may already have a left child of its own.

Instead, the node is replaced by its inorder successor, the node with the next-highest key. The successor is larger than everything in the deleted node's left subtree and no larger than anything else in its right subtree, so it can take the deleted node's place without breaking the order.

Finding the Successor

Go to the node's right child, then follow left-child pointers until you reach a node with no left child. That node is the successor. Every step to the left leads to a smaller key, so the node where you stop holds the smallest key on the right.

1225delete me30successor3540455075
To delete 25, go to its right child, 40, then left as far as possible. The trip stops at 30, which has no left child, so 30 is the successor. It does have a right child, 35.

The successor can't have a left child, or the trip would have gone further. It may have a right child, though, and that child must be looked after when the successor moves. getSuccessor() finds the successor and, if it lies deeper than the right child, also makes two of the new connections.

tree.cpp Finding and unhooking the inorder successor
// Find the node that will take pDelNode's place (the next-higher key).
// If it lies deeper than pDelNode's right child, unhook it first.
Node* Tree::getSuccessor(Node* pDelNode)
   {
   Node* pSuccessorParent = pDelNode;
   Node* pSuccessor = pDelNode->pRightChild;
   // the smallest key on the right is found by going left as far as possible
   while(pSuccessor->pLeftChild != NULL)
      {
      pSuccessorParent = pSuccessor;
      pSuccessor = pSuccessor->pLeftChild;
      }
   // the successor is further down than pDelNode's right child
   if(pSuccessor != pDelNode->pRightChild)
      {
      // step 1: the successor's right subtree moves into its old slot
      pSuccessorParent->pLeftChild = pSuccessor->pRightChild;
      // step 2: the successor takes over pDelNode's right subtree
      pSuccessor->pRightChild = pDelNode->pRightChild;
      }
   return pSuccessor;
   }

pSuccessor slides left from the right child while pSuccessorParent trails one step behind. If the successor is the right child itself, the function just returns it. Otherwise it first carries out steps 1 and 2 of the list below. The book's version walks down with a third pointer, pCurrent, until that pointer becomes NULL, which comes to the same thing.

When the Successor Is the Right Child

If the deleted node's right child has no left child, that right child is the successor, and the job is easy. The successor moves up into the deleted node's place, keeps its own right subtree, and adopts the deleted node's left child into its empty left slot. In the figure above, deleting 40 would work this way. 40's right child, 45, has no left child, so 45 moves up under 25 and takes 30 (with 35 still attached) as its left subtree.

When the Successor Is Further Down

When the successor lies deeper, somewhere down the left side of the right subtree, four pointers change. In the figure, 25 is being deleted and its successor is 30, which has a right child, 35.

  1. Plug the successor's right child into the slot the successor is leaving, the left pointer of the successor's parent. 40's left child becomes 35.
  2. Plug the deleted node's right child into the successor's right pointer. 30's right child becomes 40.
  3. Point the deleted node's parent at the successor, or set pRoot to it if the root is being deleted. 50's left child becomes 30.
  4. Give the successor the deleted node's left child. 30's left child becomes 12.
12303540455075
After deleting 25. The successor 30 sits in 25's old place, 35 has moved up into 30's old slot under 40, and the tree is still in order.

Steps 1 and 2 happen in getSuccessor(), and steps 3 and 4 in remove(). Step 1 must come before step 2, or overwriting the successor's right pointer would lose 35 and everything below it. Here is the end of remove(), including the two-children case; the search has already set pCurrent, pParent and isLeftChild.

tree.cpp The two-children case at the end of remove()
else
   {
   // case 3: two children, so the inorder successor moves up
   pReplacement = getSuccessor(pCurrent);
   // step 4: the successor adopts pCurrent's left subtree
   pReplacement->pLeftChild = pCurrent->pLeftChild;
   }

// step 3, and the final link for every case: the parent (or pRoot)
// now points at the replacement instead of pCurrent
if(pCurrent == pRoot)
   pRoot = pReplacement;
else if(isLeftChild)
   pParent->pLeftChild = pReplacement;
else
   pParent->pRightChild = pReplacement;

delete pCurrent;
return true;

Each case in this version only chooses a pReplacement for pCurrent. For a leaf it is NULL, for one child it is that child, and for two children it is the successor, after the successor has adopted pCurrent's left subtree. One block then points the parent, or pRoot, at the replacement, and delete frees the old node. The book writes the parent connection out inside each case, with the same logic. The complete remove() is part of the full program in Hour 16.

Is Deletion Necessary?

Because deletion is so involved, some programs skip it. Each node gets a bool field such as isDeleted, and deleting a node just sets the flag to true. The other operations treat marked nodes as if they weren't there.

The tree never shrinks this way, and dead nodes still sit on the search paths. That is acceptable when deletions are rare, or when old records must be kept anyway, as with a company's files on its former employees.

Summary

  • An ordered array searches in O(log N) but inserts and deletes in O(N). A linked list inserts and deletes quickly but searches in O(N). A balanced binary search tree does all three in O(log N).
  • A tree is a set of nodes joined by edges. It has one root, every other node has exactly one parent, and nodes without children are leaves. The root is on level 0.
  • In a binary search tree each node has at most two children. Keys in its left subtree are smaller than its key, and keys in its right subtree are greater than or equal to it.
  • A disk's directory structure is a familiar multiway tree, and a full file name is a path from the root to a leaf.
  • Keys that arrive in sorted or nearly sorted order build a lopsided tree that searches slowly.
  • In C++, a Node holds the data plus pLeftChild and pRightChild, and the Tree holds only pRoot.
  • To find a key, go left for smaller keys and right for larger ones. A missing child means the key isn't there.
  • A new node goes where the search for it runs out of tree, so it always becomes a leaf. A trailing pParent pointer lets the code attach it.
  • Deleting a leaf sets the parent's pointer to NULL, and deleting a node with one child links its parent to that child. A node with two children is replaced by its inorder successor, the leftmost node of its right subtree.

Q&A

If my data rarely changes, do I still need a tree?
Maybe not. An ordered array is simpler, and binary search on it is as fast as a tree search. The tree pays off when insertions and deletions are frequent. With a million items, an array insertion moves about half a million items on average, while a tree insertion needs about 20 comparisons and one pointer change.
Could deletion use the next-lower key instead of the next-higher one?
Yes. The node with the next-lower key is the largest key in the left subtree, found by going left once and then right as far as possible. It works just as well, with every left and right swapped. The book uses the successor, and so does the code here.
Why does the Tree class hold only a pointer to the root?
Because every other node can be reached from the root by following child pointers. It also makes an empty tree easy to recognize, since pRoot is simply NULL.
Is every binary tree a binary search tree?
No. A binary tree only limits each node to two children. The ordering rule is what makes it a search tree. The expression trees in Hour 16 are binary trees whose shape follows an arithmetic formula instead of key order.

Quiz

  1. In a binary search tree, which child of a node has a key smaller than the node's key?

    Answer

    The left child. In fact every node in the left subtree has a smaller key, and every node in the right subtree has a key greater than or equal to the node's.

  2. What is the largest number of nodes a binary tree with five levels can hold?

    Answer

    31, because 1 + 2 + 4 + 8 + 16 = 31. That is the limit of the Tree Workshop applet.

  3. Where does a newly inserted node always end up?

    Answer

    At a leaf position. It becomes the left or right child of the last node the search visited, in the spot where the search found a missing child.

  4. What are the three cases you must handle when deleting a node?

    Answer

    No children (set the parent's pointer to NULL), one child (connect the parent directly to that child), and two children (replace the node with its inorder successor).

  5. How do you find the inorder successor of a node that has two children?

    Answer

    Go to its right child, then keep going to left children until you reach a node with no left child. That node holds the smallest key in the right subtree.

  6. What is the Big O time for finding a node in a balanced binary search tree of N nodes?

    Answer

    O(log N). The search visits one node per level, and a balanced tree of N nodes has about log₂N levels.

16

Part IV · Trees

Hour 16 Traversing Binary Trees

Traversing a tree means visiting every node once, in a fixed order. This hour shows the three standard orders and the short recursive functions behind them, finds the smallest and largest keys, measures how fast trees really are, and assembles everything into a complete C++ program.

In this hour you'll learn

  • How inorder, preorder and postorder traversals work, and how recursion carries them out
  • How an expression tree turns into prefix and postfix notation
  • How to find the minimum and maximum keys
  • Why tree operations take O(log N) time, and what happens to duplicate keys
  • How the complete tree.cpp program fits together

Traversing the Tree

To traverse a tree is to visit every node exactly once, in some set order. Programs traverse trees less often than they find, insert or delete nodes, but traversal is how you print a tree's contents in sorted order or process every record in it. It is also one of the neatest uses of recursion (Hours 11 and 12) you'll ever see.

There are three simple orders, named for when a node is visited compared with its two subtrees. Preorder visits the node before its subtrees, inorder between them, and postorder after them. For binary search trees, inorder is the one you'll use most.

Inorder Traversal

An inorder traversal of a binary search tree visits the nodes in ascending order of their keys. The tree is never sorted into a list, yet a few lines of code read it out in order. When you need a sorted listing of the data, this is how you get it.

The recursive function inOrder() receives a pointer, pLocalRoot, to the node at the top of the subtree it should traverse. It does three things:

  1. Call itself to traverse the node's left subtree.
  2. Visit the node.
  3. Call itself to traverse the node's right subtree.

The base case is an empty subtree. When pLocalRoot is NULL, the function returns at once without doing anything.

tree.cpp Inorder traversal
void Tree::inOrder(Node* pLocalRoot)
   {
   // an empty subtree: nothing to do, and the recursion stops here
   if(pLocalRoot == NULL)
      return;
   // first every smaller key, then this node, then every larger key
   inOrder(pLocalRoot->pLeftChild);
   cout << pLocalRoot->iData << " ";
   inOrder(pLocalRoot->pRightChild);
   }

The keys come out in ascending order because every key in the left subtree is smaller than the node's key, so step 1 prints all of them first, and every key in the right subtree is larger, so step 3 prints them afterward. The same reasoning holds inside each subtree, all the way down. A traversal of the whole tree starts with the call inOrder(pRoot).

Here, visiting a node means printing its key. It could just as well mean adding up salaries, writing records to a file or counting nodes.

Traversing a Three-Node Tree

To watch the recursion at work, trace inOrder() on a tiny tree with root A, left child B and right child C. The letters are only names, so you can follow the order of the visits.

Bvisited 1stAvisited 2ndCvisited 3rd
A three-node tree. An inorder traversal visits the left child, then the root, then the right child, printing B A C.
The calls made by inOrder(A)
StepCall runningWhat it does
1inOrder(A)Calls inOrder(B) for A's left subtree
2inOrder(B)Calls inOrder(NULL) for B's left child
3inOrder(NULL)Finds nothing and returns at once
4inOrder(B)Visits B
5inOrder(B)Calls inOrder(NULL) for B's right child, which returns at once
6inOrder(B)Is finished and returns to inOrder(A)
7inOrder(A)Visits A
8inOrder(A)Calls inOrder(C) for its right subtree
9inOrder(C)Calls inOrder(NULL) for C's left child, which returns
10inOrder(C)Visits C
11inOrder(C)Calls inOrder(NULL) for C's right child, then returns to inOrder(A)
12inOrder(A)Is finished and returns to its caller; the traversal is over

The output is B A C. The function was called seven times, three times for real nodes and four times for NULL children, and every call returned to the one that made it.

Traversing with the Workshop Applet

In the Tree Workshop applet, each press of Trav moves the inorder traversal one step. The red arrow shows the node the algorithm has reached, and each time a node is visited its key joins a list at the bottom of the display. For a tree with 50 at the root, 30 and 60 as its children, and 20 and 40 below 30, the list grows 20, then 20 30, then 20 30 40, then 20 30 40 50, and finally 20 30 40 50 60.

Notice how the traversal moves. It first runs down the left edge to the smallest key without visiting anything on the way. After that, as the recursive calls return, it climbs back to parents to visit them and dives into right subtrees, passing through some nodes more than once while visiting each only once.

201302403504605706807
The order in which an inorder traversal visits a seven-node tree. The keys come out as 20 30 40 50 60 70 80, the same order you get by reading the nodes from left to right across the page.

Preorder and Postorder Traversals

Move the visit and you get the other two orders. A preorder traversal visits the node first, then traverses its left subtree, then its right subtree. A postorder traversal traverses the left subtree, then the right subtree, and visits the node last. The code has the same shape as inOrder(); only the line that visits the node moves.

tree.cpp Preorder and postorder traversals
void Tree::preOrder(Node* pLocalRoot)
   {
   if(pLocalRoot == NULL)
      return;
   // visit the node before either subtree
   cout << pLocalRoot->iData << " ";
   preOrder(pLocalRoot->pLeftChild);
   preOrder(pLocalRoot->pRightChild);
   }

void Tree::postOrder(Node* pLocalRoot)
   {
   if(pLocalRoot == NULL)
      return;
   // visit the node only after both subtrees
   postOrder(pLocalRoot->pLeftChild);
   postOrder(pLocalRoot->pRightChild);
   cout << pLocalRoot->iData << " ";
   }
The three orders applied to the seven-node tree above
OrderRuleOutput
Preordernode, left, right50 30 20 40 70 60 80
Inorderleft, node, right20 30 40 50 60 70 80
Postorderleft, right, node20 40 30 60 80 70 50

Preorder and postorder don't list a binary search tree in sorted order, so they are less useful there. They come into their own with other kinds of binary trees. One example is an expression tree, also called a parse tree, which represents an arithmetic expression. Each operator is a node, and its two children are its operands, which may be single variables or whole subexpressions.

A*B+C
The expression A*(B+C) as a tree. The * is at the root with A as its left operand, and the subexpression B+C is its right operand.

Traversing this tree in each order writes the expression in a different notation.

  • Inorder gives A*B+C, the familiar infix form with each operator between its operands. The parentheses are lost, though, and without them the expression means (A*B)+C. To print a correct infix expression, you'd output an opening parenthesis before each operator's left operand and a closing one after its right operand.
  • Preorder gives *A+BC. This is prefix notation, in which each operator comes before its two operands. It never needs parentheses. The * applies to A and to +BC, and +BC means B+C.
  • Postorder gives ABC+*. This is postfix notation, also called Reverse Polish Notation, in which each operator follows its operands. Some calculators work this way, and a postfix expression is easy to evaluate with a stack (Hour 6).

Postorder has one more job in C++. To free every node in a tree, you must delete a node's children before the node itself, because once a node is gone you can't safely follow its pointers. A postorder traversal handles children first, so it is exactly what the tree's destructor needs, as the full program shows.

Finding Maximum and Minimum Values

The ordering rule makes the extremes easy to find. For the minimum, start at the root and go to its left child, then to that node's left child, and so on until you reach a node with no left child. That node holds the smallest key in the tree. For the maximum, keep going right instead.

12minimum253037435062758793maximum
The minimum is found by going left from the root until there is no left child (50, 25, 12). The maximum is found by going right until there is no right child (50, 75, 87, 93).
tree.cpp Finding the smallest and largest keys
Node* Tree::minimum()
   {
   // an empty tree has no smallest key
   if(pRoot == NULL)
      return NULL;
   Node* pCurrent = pRoot;
   // keep stepping left until there is no left child
   while(pCurrent->pLeftChild != NULL)
      pCurrent = pCurrent->pLeftChild;
   return pCurrent;
   }

Node* Tree::maximum()
   {
   if(pRoot == NULL)
      return NULL;
   Node* pCurrent = pRoot;
   // the mirror image: keep stepping right
   while(pCurrent->pRightChild != NULL)
      pCurrent = pCurrent->pRightChild;
   return pCurrent;
   }

Each function follows a single path down and returns a pointer to the node it finds, or NULL for an empty tree. The book's minimum() walks on until pCurrent becomes NULL and returns the last real node, which it keeps in pLast. Stopping one step earlier, as here, gives the same answer.

The minimum node has no left child, but it may have a right child, so it isn't necessarily a leaf. The successor search of Hour 15 is really a minimum search that starts at a node's right child. Both functions visit one node per level, so they are O(log N) in a balanced tree.

The Efficiency of Binary Trees

Finding, inserting and deleting all walk down the tree one level at a time, doing a fixed amount of work at each level. Their running time is therefore proportional to the number of levels, and the question is how many levels a tree of N nodes has.

Look at full trees, in which every level is completely filled. Each level holds twice as many nodes as the one above it, so the totals grow quickly.

Nodes and levels in a full binary tree
Number of nodesNumber of levels
11
32
73
154
315
1,02310
32,76715
1,048,57520
33,554,43125
1,073,741,82330

A full tree with L levels holds 2L − 1 nodes, so L = log₂(N+1). Real trees are seldom full, but a balanced one has about log₂N levels, which makes the common operations O(log N). A balanced tree of a million nodes needs about 20 comparisons per search, and one of a billion nodes about 30.

Now compare the other structures. An ordered array also searches in O(log N), but inserting into a million-item array moves 500,000 items on average, while a tree insertion takes about 20 comparisons and one pointer change. Deletion from the array is just as slow. A linked list can insert at its front instantly, but finding or deleting a particular item means walking through half the list.

Speed of the basic operations
StructureSearchInsertionDeletion
Unordered arrayO(N)O(1)O(N)
Ordered arrayO(log N)O(N)O(N)
Linked listO(N)O(1)O(N)
Binary search tree, balancedO(log N)O(log N)O(log N)
Binary search tree, degenerated into a lineO(N)O(N)O(N)

Only the tree is fast at all three, as long as it stays balanced. Traversal is the one slower operation. It has to visit every node, so it is O(N) whatever the tree's shape, but that is rarely a problem because traversal is seldom part of the time-critical work of a program.

Duplicate Keys

Look again at insert() from Hour 15. When the new key equals the current node's key, the code takes the else branch and goes right. So a duplicate always lands in the right subtree of its twin, where it becomes the node that comes right after its twin in key order.

305050′second 5070
Inserting a second 50 (marked 50′) into the tree 50, 30, 70. It goes right at the first 50, then left at 70, and ends up as 70's left child.

The trouble is that find() stops at the first match, the copy nearest the root, and never sees the others. You have two choices. You can forbid duplicates by having insert(), or the code that calls it, check with find() first and refuse a key that is already there. Or you can modify find() and the other functions to keep searching the right subtree for further copies. Many applications, such as a database keyed on unique ID numbers, never allow duplicates anyway.

Implementing a Binary Search Tree in C++

The pieces from this hour and the last come together in the program tree.cpp. The Node class is the one from Hour 15. The Tree class gains the traversal functions, minimum() and maximum(), a display function and a destructor. Here is its declaration.

tree.cpp The complete Tree class declaration
class Tree
   {
   private:
      // the only node the Tree object holds directly
      Node* pRoot;
      // helpers that work on one subtree at a time
      Node* getSuccessor(Node* pDelNode);
      void preOrder(Node* pLocalRoot);
      void inOrder(Node* pLocalRoot);
      void postOrder(Node* pLocalRoot);
      void destroy(Node* pLocalRoot);
   public:
      Tree() : pRoot(NULL)
         { }
      // free every node when the tree goes away
      ~Tree()
         { destroy(pRoot); }
      Node* find(int key);
      void insert(int id, double dd);
      bool remove(int key);
      Node* minimum();
      Node* maximum();
      void traverse(int traverseType);
      void displayTree();
   };

The recursive helpers take a Node* so they can call themselves on subtrees. Code outside the class has no access to nodes, so the helpers are private, and the public traverse() starts the chosen one at pRoot. find(), insert() and getSuccessor() are exactly as shown in Hour 15.

The remove() Function

Here is the complete remove(), with the search and all three deletion cases.

tree.cpp Deleting the node with a given key
bool Tree::remove(int key)
   {
   Node* pCurrent = pRoot;
   Node* pParent = NULL;
   bool isLeftChild = false;
   // search for the node, noting its parent and which side it hangs on
   while(pCurrent != NULL && pCurrent->iData != key)
      {
      pParent = pCurrent;
      isLeftChild = (key < pCurrent->iData);
      if(isLeftChild)
         pCurrent = pCurrent->pLeftChild;
      else
         pCurrent = pCurrent->pRightChild;
      }
   // key not found (this also covers an empty tree)
   if(pCurrent == NULL)
      return false;

   // decide which node, if any, moves into pCurrent's position
   Node* pReplacement;
   if(pCurrent->pLeftChild == NULL && pCurrent->pRightChild == NULL)
      // case 1: a leaf simply disappears
      pReplacement = NULL;
   else if(pCurrent->pRightChild == NULL)
      // case 2: only a left child, so that subtree moves up
      pReplacement = pCurrent->pLeftChild;
   else if(pCurrent->pLeftChild == NULL)
      // case 2 again: only a right child, so that subtree moves up
      pReplacement = pCurrent->pRightChild;
   else
      {
      // case 3: two children, so the inorder successor moves up
      pReplacement = getSuccessor(pCurrent);
      // step 4: the successor adopts pCurrent's left subtree
      pReplacement->pLeftChild = pCurrent->pLeftChild;
      }

   // step 3, and the final link for every case: the parent (or pRoot)
   // now points at the replacement instead of pCurrent
   if(pCurrent == pRoot)
      pRoot = pReplacement;
   else if(isLeftChild)
      pParent->pLeftChild = pReplacement;
   else
      pParent->pRightChild = pReplacement;

   delete pCurrent;
   return true;
   }

The loop is the search, recording pParent and isLeftChild on the way down. The chain of if tests then picks pReplacement, which is NULL for a leaf, the only child when there is just one, or the inorder successor when there are two. A single block links the replacement into the parent (or pRoot), and delete frees the node.

The book's remove() repeats that linking block inside each case, and it assumes the tree isn't empty, because it reads the root's key before testing anything. This version's loop tests pCurrent first, so for an empty tree it simply returns false.

Traversing and Cleaning Up

tree.cpp Choosing a traversal, and deleting every node
void Tree::traverse(int traverseType)
   {
   // start the chosen recursive traversal at the root
   switch(traverseType)
      {
      case 1:
         cout << "Preorder: ";
         preOrder(pRoot);
         break;
      case 2:
         cout << "Inorder: ";
         inOrder(pRoot);
         break;
      case 3:
         cout << "Postorder: ";
         postOrder(pRoot);
         break;
      default:
         cout << "Unknown traversal type";
      }
   cout << endl;
   }

// Delete every node below and including pLocalRoot, children first.
void Tree::destroy(Node* pLocalRoot)
   {
   if(pLocalRoot == NULL)
      return;
   destroy(pLocalRoot->pLeftChild);
   destroy(pLocalRoot->pRightChild);
   delete pLocalRoot;
   }

traverse() uses a switch to start preOrder(), inOrder() or postOrder() at the root. The destructor calls destroy(pRoot), which is a postorder traversal whose visit is delete. Children are freed before their parent, so no pointer is followed after its node is gone. Without a destructor, every node would stay allocated after the Tree object disappeared. The helper's name is ours; what matters is the postorder.

Displaying the Tree

displayTree() draws the tree in text, one level per line, so you can check its shape. It prints 32 blanks before the root and halves the spacing on each new line, which keeps every node roughly centered above its two children. A missing node prints as --, and the display stops after a row in which no node has a child.

The book's version keeps the nodes of the current row on a stack. As it prints each node, it pushes that node's two children onto a second stack for the next row, or two placeholders if the node itself is missing, so every position lines up. The columns align only when all keys are two digits wide.

The main() Function

main() creates a Tree and inserts 11 nodes with the keys 50, 25, 75, 12, 37, 43, 30, 33, 87, 93 and 97, which produce the tree in the figure. It then loops, asking you to type the first letter of show, insert, find, delete or traverse. The i, f and d choices go on to ask for a key, and t asks for 1, 2 or 3 to choose preorder, inorder or postorder.

Level 0Level 1Level 2Level 3Level 41225303337435075879397
The tree built by main() from the keys 50, 25, 75, 12, 37, 43, 30, 33, 87, 93 and 97. The right side has grown a long branch because 87, 93 and 97 arrived in ascending order.

Here is a short session. Showing the tree prints it level by level, and an inorder traversal lists the keys in ascending order. Deleting 25, a node with two children, brings its successor 30 up into its place, and 33 moves into 30's old slot under 37.

Enter s(how), i(nsert), f(ind), d(elete) or t(raverse): s
......................................................
                                50
                25                              75
        12              37              --              87
    --      --      30      43      --      --      --      93
  --  --  --  --  --  33  --  --  --  --  --  --  --  --  --  97
......................................................
Enter s(how), i(nsert), f(ind), d(elete) or t(raverse): t
Type 1 (preorder), 2 (inorder) or 3 (postorder): 2
Inorder: 12 25 30 33 37 43 50 75 87 93 97
Enter s(how), i(nsert), f(ind), d(elete) or t(raverse): d
Key to delete: 25
Deleted 25
Enter s(how), i(nsert), f(ind), d(elete) or t(raverse): s
......................................................
                                50
                30                              75
        12              37              --              87
    --      --      33      43      --      --      --      93
  --  --  --  --  --  --  --  --  --  --  --  --  --  --  --  97
......................................................

Try the other deletion cases as well, such as 33 (a leaf) or 87 (one child), and show the tree after each one to see exactly what moved. A preorder traversal of the original tree prints 50 25 12 37 30 33 43 75 87 93 97, and a postorder traversal prints 12 33 30 43 37 25 97 93 87 75 50.

Summary

  • Traversing a tree visits every node once. The three standard orders are preorder (node, left, right), inorder (left, node, right) and postorder (left, right, node).
  • Each traversal is a short recursive function whose base case is a NULL subtree. Only the position of the visit differs.
  • An inorder traversal of a binary search tree visits the keys in ascending order.
  • In an expression tree, inorder gives infix notation (without its parentheses), preorder gives prefix notation, and postorder gives postfix notation.
  • Postorder deletes children before their parent, which is what a C++ destructor needs.
  • The minimum is at the end of the path of left children from the root, and the maximum at the end of the path of right children.
  • A full tree of L levels holds 2L − 1 nodes, so a balanced tree of N nodes has about log₂N levels. Find, insert and delete are O(log N); traversal is O(N).
  • The insertion code sends a duplicate key to the right, but find() sees only the first copy, so many programs forbid duplicates.
  • The tree.cpp program combines find, insert, remove, three traversals, a text display and a destructor behind a simple menu.

Q&A

Can a tree be traversed without recursion?
Yes. The recursive version quietly uses the computer's own stack to remember where to return. You can do the same work with an explicit stack of node pointers (Hour 6), pushing nodes as you go left and popping them to visit. The recursive version is much shorter, which is why the book uses it.
Does a traversal care whether the tree is balanced?
The number of visits is N either way. The depth of the recursion does depend on the shape. In a balanced tree the calls nest about log₂N deep, but in a tree that has degenerated into a line they nest N deep, which for a very large tree could use up the stack.
What is a preorder listing good for in a binary search tree?
It records the tree's shape. Every node comes before its descendants, so if you insert the keys into an empty tree in preorder, you get back exactly the same tree.
How do I keep duplicates out of the tree?
Call find() before insert() and skip the insertion if the key is already there, or build the same check into insert() itself.

Quiz

  1. A binary search tree has 40 at the root, 20 and 60 as its children, 10 and 30 as the children of 20, and 50 as the left child of 60. What does an inorder traversal print?

    Answer

    10 20 30 40 50 60. An inorder traversal of a binary search tree always produces the keys in ascending order.

  2. What does a preorder traversal of the expression tree for A*(B+C) produce, and what is that notation called?

    Answer

    *A+BC, which is prefix notation. Each operator comes before its two operands.

  3. How do you find the node with the largest key?

    Answer

    Start at the root and keep going to the right child until you reach a node with no right child. That node holds the maximum.

  4. About how many levels does a balanced tree of 1,000,000 nodes have?

    Answer

    About 20, since a full tree of 20 levels holds 1,048,575 nodes. A search therefore needs about 20 comparisons.

  5. Where does insert() put a key that duplicates one already in the tree?

    Answer

    In the right subtree of the existing node, because an equal key takes the "go right" branch. It becomes the node that follows its twin in key order.

  6. Why does the Tree destructor delete the nodes in postorder?

    Answer

    Postorder handles both children before their parent. If a parent were deleted first, its child pointers could no longer be followed safely, and the nodes below it would be lost without being freed.

17

Part IV · Trees

Hour 17 Red-Black Trees

An ordinary binary search tree turns slow when its data arrives in order. A red-black tree prevents that by coloring every node and obeying four rules that keep the tree balanced. This hour builds your feel for those rules through hands-on experiments with color flips and rotations.

In this hour you'll learn

  • Why sorted input cripples an ordinary binary search tree
  • The four red-black rules, black height and null children
  • How to drive the RBTree Workshop applet
  • What color flips and rotations do, through four experiments
  • How a rotation moves nodes and whole subtrees while keeping the tree in order

Our Approach to the Discussion

Red-black trees are among the most complicated structures in this book. The insertion algorithm has several special cases, and the code is long and hard to follow. So the book doesn't start with code. It takes a conceptual approach, and you learn by experimenting with the RBTree Workshop applet.

The applet lets you insert nodes and then repair the tree yourself with color changes and rotations, while it tells you whenever a rule is broken. After some trial and error you'll have a feel for what each repair does. Hour 18 turns that feel into precise rules for inserting a node.

Top-Down Insertion

There are two ways to organize a red-black insertion. In top-down insertion, the algorithm may change the tree's structure on its way down from the root to the insertion point, clearing away trouble before it gets there. In bottom-up insertion, it first finds the insertion point and inserts the node, then works back up the path repairing any damage. The book uses the top-down approach.

Balanced and Unbalanced Trees

Try inserting the keys 10, 20, 30, 40 and 50, in that order, into an ordinary binary search tree. 10 becomes the root. 20 is larger, so it becomes 10's right child, and 30 is larger than both, so it becomes 20's right child. Every new node ends up as the right child of the one before.

1020304050
The keys 10, 20, 30, 40 and 50 inserted in ascending order. Every node is the right child of the one before, so the tree has become a line, and finding 50 means visiting every node.

Degenerates to O(N)

The result is really a linked list with an unused left pointer in every node. To find 50 you must pass through every node, so searching is O(N) instead of O(log N). With 10,000 items a search could take 10,000 comparisons, where a balanced tree would need about 14. Insertion suffers just as much, since it has to find its place first.

This happens whenever the data arrives already sorted. Ascending keys send every node to the right, descending keys send every node to the left, and partly sorted data builds a partly lopsided tree. Data often does arrive in order, for example when it is read from a file that was sorted earlier, so the problem is a practical one.

Balance to the Rescue

A balanced tree has roughly the same number of descendants on the left of each node as on its right, so its number of levels stays close to log₂N. The hard part is staying balanced as nodes come and go. A red-black tree does it by checking a few rules during every insertion and deletion and rearranging the tree when a rule is broken. The checks are local and cheap, so the balance costs little.

Red-Black Tree Characteristics

A red-black tree is a binary search tree with two extra features. First, every node has a color, red or black. The applet draws red and black circles, and in code the color is just one more field in the node. Second, whenever a node is inserted or deleted, certain rules about the arrangement of colors are enforced, and those rules are what keep the tree balanced.

The Red-Black Rules

Here are the four rules.

  1. Every node is either red or black.
  2. The root is always black.
  3. If a node is red, its children must be black. The reverse isn't required, so a black node may have black children.
  4. Every path from the root to a leaf, or to a null child, must contain the same number of black nodes.

The number of black nodes on a path from the root to a leaf is the path's black height, so rule 4 says that every such path has the same black height. In the valid tree below, every path from 50 down to a missing child passes through exactly two black nodes.

12253750627587
A valid red-black tree. The root is black, both red nodes have black children, and every path from 50 to a missing child passes through two black nodes, so the black height is 2.

A null child is a child that could exist but doesn't. A leaf has two null children, and a node with only a right child has a null left child. Rule 4 counts the paths that end at null children as well as the paths that end at leaves. That matters, because a lopsided shape can hide behind a missing child. A black root with a single black child has only one leaf, so counting leaves alone would find nothing wrong, while the path to the root's null child shows the imbalance at once.

12255075
Rule 4 broken. Every path from 50 down through 25 reaches a null child after two black nodes, 50 and 25. The paths through red 75 reach 75's null children after only one black node, 50, so they are the short ones.

Duplicate Keys

Duplicate keys are awkward in red-black trees. A rotation can carry a node from the right side of its twin to the left side, so the convention that equal keys go right no longer holds, and a search can miss some copies. The discussion in these hours assumes that every key is unique.

The Actions

When an insertion breaks a rule, two kinds of action can repair it. You can change the colors of nodes, and one particular three-node recoloring, the color flip, is especially useful. Or you can perform rotations, which rearrange nodes so that some move up and others move down. Insertion and deletion use combinations of these to keep all four rules true.

Using the RBTree Workshop Applet

The RBTree Workshop applet draws a red-black tree with red and black nodes. Unlike the Tree applet, it doesn't fix anything for you. It inserts a node wherever an ordinary binary search tree would put it, and then it's up to you to repair the tree. After every action it checks the four rules and reports what it finds.

Clicking on a Node

Several buttons act on one particular node, so you first select a node by clicking it. A red arrow then points to it. That is the node that will be flipped, rotated around, or recolored.

The Buttons

The RBTree applet's buttons
ButtonWhat it does
StartMakes a new tree containing only a black root with the key 50.
InsInserts a node with the key typed in the Number box. New nodes are always red, and nothing is fixed afterward.
DelDeletes the node with the typed key. (Our re-creation leaves this button out.)
FlipColor flip on the selected node. A black node with two red children turns red and its children turn black, except that the root stays black.
RoLRotates left, with the selected node at the top of the rotation.
RoRRotates right, with the selected node at the top of the rotation.
R/BToggles the selected node between red and black, for experimenting.

Text Messages

A message area reports on the tree after each action. It tells you when a red node has a red child, when black heights differ, or when the root isn't black, and it confirms when the tree is red-black correct. The re-created applet shows the same information in a status line that checks all four rules.

Where's the Find Button?

There isn't one. Searching a red-black tree works exactly like searching any binary search tree, because colors play no part in a search. And Ins already performs that search to locate the insertion point, so a separate Find button would show nothing new.

Below is the re-created applet in manual mode. The steps walk you through the first experiment in the next section.

Experimenting

Each of the following experiments teaches one idea. Try them in the applet above as you read.

Experiment 1: Inserting Two Red Nodes

Press Start to get a lone black 50, then insert 25. The new red node becomes 50's left child. A red child under a black parent breaks no rule, and every path still holds one black node. Insert 75 and it becomes a red right child, which is also legal. You now have a black node with two red children, a perfectly balanced little tree.

Now insert 12. It goes to the left of 25, and since 12 and 25 are both red, rule 3 is broken. The fix is a color flip at 50. Select 50 and press Flip. 25 and 75 turn black, and 50 would turn red, but it is the root, so it stays black. 12 is now a red child of a black node, and all four rules hold.

12255075
Before the flip: 12 is red under red 25, so rule 3 is broken.
12255075
After flipping at 50: 25 and 75 are black, the root stays black, and the tree is correct.

Look at what the flip did to the black heights. Before it, every path held one black node; afterward every path holds two, 50 plus either 25 or 75. All paths gained the same amount, so rule 4 still holds. A flip at the root is how a red-black tree adds one to its black height.

Experiment 2: Rotations

Press Start again and insert 25, then 12. Now 50, 25 and 12 form a line down the left side. Both 25 and 12 are red, which breaks rule 3, and the tree is plainly lopsided. A flip can't help, because 50 doesn't have two red children.

The cure is a rotation. Select 50 and press RoR. 25 rises to become the root, with 12 as its left child and 50 as its right child. The shape is now balanced, but the colors moved with their nodes, so the new tree breaks three rules at once.

Finish with R/B. Select 25 and make it black, then select 50 and make it red. The result is a black root with two red children, balanced and correct. The rotation fixed the shape, and the color changes fixed the rules.

122550
Before: a line of three nodes. Rule 3 is broken, since 25 and 12 are both red.
122550
After RoR at 50: balanced, but rule 2 (red root), rule 3 (25 and 12 both red) and rule 4 (one black node toward 50, none toward 12) are broken.
122550
After R/B on 25 and on 50: a black root with two red children. All four rules hold.

Experiment 3: Color Flips and a Rotation Together

Start again and insert 25, 75 and 12, flipping at 50 when 12 arrives, as in Experiment 1. Then insert 37. It becomes 25's right child, a red node under a black one, so nothing needs fixing.

Insert 6. It lands under 12 as a red child of a red node, breaking rule 3. This time 25 is a black node with two red children, 12 and 37, so select 25 and flip it. 25 turns red and 12 and 37 turn black, and since 25's parent, 50, is black, no new conflict appears. Next insert 18, which becomes the red right child of the black 12 and needs no fixing.

6121825375075
After inserting 6, flipping at 25, and inserting 18. The tree is correct, with a black height of 2 on every path.

Now insert 3. It lands under 6, which is red, so rule 3 is broken again. 12 is black with two red children, so flip at 12. That clears the conflict at the bottom but creates a new one higher up, because 12 is now red and so is its parent, 25. A flip can push a problem up the tree.

25's sibling, 75, is black, so another flip at 50 isn't possible. Rotate instead. Select 50 and press RoR, so 25 rises to the root and 50 moves down to its right, while 25's old right child, 37, crosses over to become 50's left child. Then use R/B to make 25 black and 50 red. Every rule now holds, and the nine nodes fit in four levels, as few as nine nodes allow.

36121825375075
After inserting 3 and flipping at 12: the bottom is fixed, but rule 3 is now broken higher up, because 12 and its parent 25 are both red.
36121825375075
After RoR at 50 and recoloring 25 black and 50 red. 37 has crossed over to 50's left, and all four rules hold.

Flips and rotations work as a team. A flip moves redness up the tree, and when that leaves two reds in a row, a rotation removes them. Hour 18 turns these moves into exact rules, doing the flips on the way down so that such conflicts are always easy to fix.

Experiment 4: An Unbalanced Tree

Now try to build a lopsided tree on purpose. Press Start and insert 60, 70 and 80 without fixing anything. Each new node becomes the right child of the one before, making a line down the right side. As soon as 70 goes in, the status line reports a red node with a red child.

Try recoloring your way out. If you make 70 black to separate the reds, the paths through 70 contain two black nodes while the path from 50 to its null left child contains one, so rule 4 breaks instead. No choice of colors makes a line of three or more nodes legal, which is why these error messages are a reliable sign of imbalance.

Rotations are the cure. Press Start again and insert 60 and 70. With 50, 60 and 70 in a line, select 50, press RoL, and recolor 60 black and 50 red. Then insert 80 and flip at 60; insert 90, rotate left at 70 and make 80 black and 70 red; insert 100 and flip at 80. The keys arrived in ascending order, yet the tree stays bushy.

50607080
Ascending keys with no repairs: a line down the right. Rule 3 is broken twice (60 and 70 are red, and so are 70 and 80).
5060708090100
The same ascending keys, 50 to 100, repaired with rotations and flips after each insertion. All four rules hold.

More Experiments

Invent your own sequences, such as keys in descending order or alternating high and low values, and notice when a flip is enough and when you need a rotation. However the keys arrive, you can always repair the tree without letting it grow lopsided.

The reason is that rules 3 and 4 together forbid a lopsided shape. Rule 4 gives every path from the root to a null child the same number of black nodes, say B. Rule 3 forbids two reds in a row, so a path holds at most one red node for each black one and is at most 2B nodes long, while no path can be shorter than B. So no path is ever more than twice as long as any other. The balance is only approximate, but it is enough to keep searches O(log N).

Rotations

To balance a tree you have to move nodes physically, because recoloring never changes the shape. A rotation must do two things at once. It must raise some nodes and lower others to improve the balance, and it must keep the tree a valid binary search tree, with smaller keys to the left and larger ones to the right. A rotation that scrambled the order would break every later search.

Simple Rotations

Every rotation has a top node, which need not be the root of the whole tree. In a right rotation with node T at the top, T moves down and to the right, into the position of its own right child, and T's left child moves up to take T's place. T must have a left child, since that child is what rises. A left rotation is the mirror image. T's right child rises, T moves down to the left, and T must have a right child.

12255075
Before a right rotation with 50 at the top. 50's left child, 25, is about to rise.
12255075
After: 25 has taken 50's place, and 50 has moved down to 25's right, taking 75 with it.

In the figure, 12 rises one level along with its parent, while 75 sinks one level along with its parent. The inorder listing, 12 25 50 75, is the same before and after, which confirms that the order was kept.

The Weird Crossover Node

That example hid a complication. Suppose 25 also has a right child, 37, and you rotate right around 50 again. 25 rises and 50 comes down as 25's new right child, but that slot already holds 37. So 37 moves across to become 50's left child, a slot that fell vacant when 25 moved up.

122537crossover5075
Before the right rotation around 50. 37 is 25's right child, the inside grandchild of 50.
122537crossover5075
After: 37 has crossed over from 25's right side to 50's left side.

The order is still correct. 37 was in 25's right subtree, so it is larger than 25, and in 50's left subtree, so it is smaller than 50. As 50's left child it sits exactly where such a key belongs. 37 is called an inside grandchild of 50, because it lies on the side of 25 that faces the middle of the tree, while 12 is an outside grandchild. You'll meet these terms again in Hour 18.

Subtrees on the Move

In a real tree, each node in a rotation may carry a whole subtree, and the subtrees move with their roots. The figure shows the same right rotation with triangles standing for subtrees.

A25B50C
Before the right rotation around 50. A and B hang below 25, and C hangs on 50's right.
A25B50C
After: A has moved up a level, B has crossed from 25's right to 50's left, and C has moved down a level.

Subtree A moves up one level along with 25. Subtree B crosses over to become 50's left subtree and stays at the same depth. Subtree C moves down one level with 50. Every key in A is less than 25, every key in B lies between 25 and 50, and every key in C is greater than 50, both before and after the rotation.

However large the subtrees are, the rotation changes only three pointers: 50's left pointer, 25's right pointer, and the pointer that used to lead down to 50 (in its parent, or pRoot). The nodes inside A, B and C are not touched at all.

Human Beings Versus Computers

Rotations are hard to picture at first, and it's normal to stare at a tree wondering where each node will land. The applet makes them concrete. Rotate, look at the result, and undo it with a rotation in the other direction at the new top node. After a few tries the pattern becomes familiar.

For a computer, a rotation is easy. It is a handful of pointer assignments and takes the same time no matter how big the subtrees are. Here is a sketch of a right rotation using the Node class of Hour 15. It is our own illustration rather than the book's; Hour 18 describes what a complete insertion routine needs.

rotate.cpp Our sketch of a right rotation (not from the book)
// Rotate right around pTop and return the node that replaces it.
// The caller stores the result where pTop used to hang.
Node* rotateRight(Node* pTop)
   {
   // the left child is the node that moves up
   Node* pUp = pTop->pLeftChild;
   // the crossover subtree moves from pUp's right side to pTop's left side
   pTop->pLeftChild = pUp->pRightChild;
   // pTop moves down to become pUp's right child
   pUp->pRightChild = pTop;
   return pUp;
   }

pUp is the left child that rises. The crossover subtree moves from pUp's right side to pTop's left side, and then pTop becomes pUp's right child. The caller stores the returned pointer wherever pTop used to hang, as in pRoot = rotateRight(pRoot);, which is the third pointer change. A left rotation swaps every left and right, and either one runs in O(1) time.

Summary

  • Keys inserted in sorted order turn an ordinary binary search tree into a line, and searching degrades from O(log N) to O(N).
  • A red-black tree is a binary search tree whose nodes are colored red or black and which obeys four rules during insertion and deletion.
  • The rules: every node is red or black, the root is black, a red node's children are black, and every path from the root to a leaf or null child has the same number of black nodes, the black height.
  • A null child is a missing child where a node could have one. Rule 4 counts the paths that end at null children.
  • New nodes are always red. Broken rules are repaired by color changes, including color flips, and by rotations.
  • A color flip turns a black node with two red children red and its children black. At the root the root stays black, and the black height grows by one.
  • A rotation raises one node and lowers another while keeping the keys in order. In a right rotation the top node's left child rises, and the inside grandchild crosses over to become the old top node's left child.
  • Whole subtrees move with their roots, yet a rotation changes only three pointers.
  • Rules 3 and 4 together guarantee that no path is more than twice as long as any other, so the tree stays roughly balanced.

Q&A

Why not just rebuild the whole tree whenever it gets lopsided?
You could read the keys out in order and build a perfectly balanced tree from them, but that takes O(N) time, and the tree would be slow between rebuilds. A red-black tree does a little O(log N) work on every insertion, so it is never badly out of balance.
Do the colors slow down searching?
No. A search ignores colors completely and runs exactly as in an ordinary binary search tree. The colors cost one extra field per node and some extra work during insertion and deletion.
Is a red-black tree perfectly balanced?
No. Its paths can differ in length by up to a factor of two. That is balanced enough to guarantee O(log N) searches, and allowing that much slack is what keeps the repairs cheap.
What is the difference between top-down and bottom-up insertion?
They schedule the same kinds of repairs differently. Bottom-up insertion adds the node first and then walks back up the path fixing problems, which requires some way to get from a node to its parent. Top-down insertion fixes things during the single trip down, and that is the approach this book follows.

Quiz

  1. What are the four red-black rules?

    Answer

    1. Every node is red or black. 2. The root is always black. 3. If a node is red, its children must be black. 4. Every path from the root to a leaf or null child contains the same number of black nodes.

  2. What is the black height of a path?

    Answer

    The number of black nodes on the path from the root to a leaf or null child. Rule 4 requires it to be the same for every such path.

  3. What is a null child?

    Answer

    A child that could exist but doesn't, such as the missing left child of a node that has only a right child. Rule 4 counts the paths that end at null children, so an imbalance can't hide behind a missing child.

  4. What does a color flip do?

    Answer

    It changes a black node with two red children to red and both children to black. If the node is the root, it stays black, which raises the black height of every path by one.

  5. In a right rotation with node T at the top, which node moves up, where does T go, and what happens to the crossover node?

    Answer

    T's left child moves up into T's place, and T moves down to become that child's right child. The rising child's old right child, the inside grandchild, crosses over to become T's new left child.

  6. Why does data that arrives in sorted order hurt an ordinary binary search tree?

    Answer

    Every new key goes to the same side as the last one, so the tree becomes a line like a linked list. Searching then takes O(N) time instead of O(log N).

18

Part IV · Trees

Hour 18 Red-Black Tree Insertions

In Hour 17 you kept a red-black tree legal by hand, picking each color flip and rotation yourself. This hour turns that trial and error into a recipe a program can follow, so that every insertion leaves the tree balanced.

In this hour you'll learn

  • How top-down insertion flips colors on its way down the tree
  • What X, P, and G are, and how to tell an outside grandchild from an inside one
  • The three situations that can follow an insertion, and the rotations that repair each one
  • How to repair a conflict that a color flip creates halfway down
  • Why deletion is harder, and how efficient red-black trees are
  • What insertion code needs, and where AVL trees and multiway trees fit in

Inserting a New Node

With the RBTree applet in Hour 17, you were the brains of the operation. You inserted a node, read the complaint in the message area, and chose a flip or a rotation that looked promising. A program can't guess like that. It needs a rule for every situation, decided in advance.

This hour supplies those rules for insertion. They follow the top-down approach, in which all the repairs happen on the way down to the insertion point, plus one small fix at the bottom. Every step is judged against the four red-black rules, so keep them in view.

  1. Every node is either red or black.
  2. The root is always black.
  3. If a node is red, its children must be black.
  4. Every path from the root to a leaf or to a null child contains the same number of black nodes (the black height).

Preview of the Insertion Process

Insertion starts as in an ordinary binary search tree (Hour 15). From the root you go left for a smaller key and right for a larger one, until the next step would lead to a null child. The new node is attached there. The red-black version adds three kinds of repair work to that trip.

  1. Color flips on the way down. Whenever the search passes a black node with two red children, it flips the colors of all three nodes.
  2. Rotations after the node is inserted. The new node is always red. If its parent is red too, rule 3 is broken, and one or two rotations plus a few color changes repair it.
  3. Rotations on the way down. A flip can itself leave a red node under a red parent. The same kind of rotation fixes that at once, and then the search carries on.

Step 3 actually happens during the descent, before step 2. We'll still take them in the order listed, because the repairs after insertion are easier to picture, and the repairs on the way down reuse the same moves.

X, P, and G

The rules need names for three related nodes. X is the node that caused a rule to break. Sometimes it is the node just inserted, and sometimes it is a node whose color a flip just changed. P is X's parent, and G is X's grandparent (the parent of P).

X is an outside grandchild of G when it is on the same side of P as P is of G, so both are left children or both are right children. X is an inside grandchild when the sides differ, for example when X is a right child and P is a left child. Trace G, P, and X with a finger. A straight slant means outside, and a zigzag means inside. An outside grandchild takes one rotation to repair, and an inside grandchild takes two.

12X25P50G75
Outside grandchild. 12 is the left child of 25, and 25 is the left child of 50, so G, P, and X form a straight line.
25P37X50G75
Inside grandchild. 37 is the right child of 25, but 25 is the left child of 50, so the line from G to X bends.

Each picture has a mirror image with P on G's right side. If P were 75, a right child 87 would be an outside grandchild and a left child 62 an inside one. The rules work the same way there, with every rotation reversed.

Color Flips on the Way Down

As the search descends, it examines each node before moving on. If the node is black and both its children are red, the search performs a color flip, turning the node red and its two children black. This is the applet's Flip button from Hour 17, now applied automatically.

A flip never disturbs rule 4. Every path through the flipped node used to count a black node at the parent's level and none at the child's level. Afterward it is the other way around. The black simply slides down one level, so every black height stays the same. If the flipped node is the root, it stays black (rule 2) while its children still turn black, so every path gains one black node. All paths gain equally, rule 4 still holds, and the tree's black height grows by one, just as in Hour 17's first experiment.

Rule 3 is the one a flip can break. If the flipped node's parent is red, two reds now sit in a row, and a rotation on the way down is needed (covered at the end of this section). If the parent is black, the flip is complete.

Flipping before anything is broken clears away one troublesome arrangement, a red parent whose sibling is also red. Every black node with two red children along the path gets flipped, so by the time the search reaches the bottom, the new node's parent never has a red sibling. The repair after insertion is then always one of a few simple cases. (In Hour 20 you'll see that a flip does the same job as splitting a full node in a 2-3-4 tree.)

Here is a flip in context. Suppose you have inserted 50, 25, 75, 12, and 37. The root flip happened when 12 arrived, and 37 became a red child of black 25. Now insert 30. At 50 there is nothing to do, because its children are black. At 25 the search finds a black node with two red children and flips it. The parent of 25 is the black root, so rule 3 is safe. The search continues to 37, and 30 becomes its red left child.

1225flip here375075
Before: on its way to insert 30, the search reaches 25, a black node with two red children. Every path holds two black nodes.
1225375075
After the flip, 25 is red and its children are black. Every path still holds two black nodes.

Rotations After the Node Is Inserted

The new node is always red because a red node adds nothing to any black height, so rule 4 can't break. A new black node would lengthen only the paths running through it, which would put rule 4 out of step all over the tree. With a red node, only rule 3 is at risk.

Call the new node X. If its parent P is red, then G must exist and must be black, since the root is always black and rule 3 held before X arrived. What happens next depends on P's color and on X's position, which gives three possibilities.

Possibility 1: P Is Black

There is nothing to do. A red node under a black one breaks no rule and changes no black height. You have already seen this case twice: 37 under black 25, and 30 under black 37.

Possibility 2: P Is Red and X Is an Outside Grandchild

X and P are both red, and they sit in a straight line below G. Three steps repair the damage.

  1. Switch the color of G.
  2. Switch the color of P.
  3. Rotate with G at the top, in the direction that raises X. If X and P are left children, that is a right rotation.

Start with the root 50 and insert 25 and 75, which become red children. Insert 12. On the way down 50 gets flipped; being the root, it stays black, while 25 and 75 turn black. Then 12 goes in as a red child of black 25 (Possibility 1). Now insert 6. It becomes the red left child of red 12. X is 6, P is 12, and G is 25, so X is an outside grandchild. Switch 25 to red and 12 to black, then rotate right at 25. Node 12 rises into 25's place under 50, with 6 on its left and 25 on its right.

6X12P25G5075
Just after 6 is inserted, red 6 under red 12 breaks rule 3. X is an outside grandchild of G.
612255075
After switching the colors of G and P and rotating right at 25, black 12 is on top with two red children. Every path holds two black nodes.

The rotation turns the lopsided chain 25, 12, 6 into a small balanced triangle with the middle key on top. The color switches give that triangle a black top and two red children, so it still adds exactly one black node to every path through it, as black 25 did before.

Possibility 3: P Is Red and X Is an Inside Grandchild

When the path from G to X bends, a single rotation at G only moves the bend to the other side. The repair takes four steps.

  1. Switch the color of G.
  2. Switch the color of X (not P this time).
  3. Rotate with P at the top, in the direction that raises X. X takes P's place, and the three nodes now form a straight line.
  4. Rotate with G at the top, in the direction that raises X again.

Use the same starting tree, but insert 18 instead of 6. It becomes the red right child of red 12, so X is 18, P is 12, and G is 25, and X is an inside grandchild. Switch 25 to red and 18 to black. Rotate left at 12, so that 18 takes 12's place and 12 becomes its left child. Now 25, 18, and 12 form a straight line, as in the outside case. Rotate right at 25, and 18 rises again while 25 becomes its right child. The result is black 18 with red children 12 and 25.

12P18X25G5075
18 lands as the red right child of red 12, which breaks rule 3. The path from G to X bends, so X is an inside grandchild.
1218255075
Halfway: colors switched and a left rotation at 12. The line is straight, but the tree isn't legal yet (the path from 50 through 25 to its empty right side has only one black node).
1218255075
After the right rotation at 25, black 18 is on top with red children 12 and 25. All four rules hold.

One pattern links the two repairs. Whichever of X, P, and G finishes on top turns black, and the other two finish red. In Possibility 2 that node is P, and in Possibility 3 it is X, which is why X's color gets switched instead of P's.

What About Other Possibilities?

One more arrangement is imaginable, a red P whose sibling (X's uncle) is also red. The rotation recipe would fail there, because G would end up red above a red node. Fortunately it can't happen. If P and its sibling were both red, G would be a black node with two red children, and the search flipped every such node on its way down.

So at the bottom, P is either black, or red with a black or missing sibling. The three possibilities cover everything, and that is the real payoff of the flips.

Rotations on the Way Down

A flip turns the flipped node red. If its parent is red too, rule 3 breaks in the middle of the descent, and the algorithm repairs it right away, before taking another step down. The moves are the ones you just learned, with a new cast. X is now the node that was flipped. For an outside grandchild, switch the colors of G and P and rotate once at G. For an inside grandchild, switch the colors of G and X, rotate at P, and then rotate at G. Each example below builds a tree and then inserts one key that triggers the problem.

Outside Grandchild on the Way Down

Insert 50, 25, 75, 12, 37, 6, and 18. Along the way 12 triggers the root flip, and 6 triggers a flip at 25 (harmless, because 25's parent is black). By the end, 6 and 18 are red children of black 12.

Now insert 3. The search passes red 25 and reaches 12, which is black with two red children, so it flips. But 12's parent, 25, is red. With X = 12, P = 25, and G = 50, X is an outside grandchild. Switch 50 to red and 25 to black, then rotate right at 50. Since 50 was the root, 25 becomes the new root, and 37 crosses over from 25 to become 50's left child (the crossover node of Hour 17). The search resumes from 12, and 3 settles in as a red child of black 6.

612flip here1825375075
The tree after 50, 25, 75, 12, 37, 6, 18. On the way to insert 3, the search meets 12, which is black with two red children.
612X1825P3750G75
After the flip, red 12 sits under red 25, which breaks rule 3. X is an outside grandchild, so one rotation at G is enough.
36121825375075
After switching 50 and 25 and rotating right at 50, 25 is the root and 37 has crossed over to 50. Then 3 goes in under black 6.

Inside Grandchild on the Way Down

For the bent version, insert 50, 25, 75, 12, 37, 31, and 43. This time 31 triggers the flip at 25, and by the end 31 and 43 are red children of black 37.

Now insert 28. The search reaches 37, black with two red children, and flips it, which leaves red 37 under red 25. With X = 37, P = 25, and G = 50, X is an inside grandchild. Switch 50 to red and 37 to black. Rotate left at 25: 37 takes 25's place, and 31 crosses over to become 25's right child. Rotate right at 50: 37 becomes the root, and 43 crosses over to become 50's left child. Each rotation moves one crossover node. The search continues from 37, and 28 becomes a red child of black 31.

12253137flip here435075
The tree after 50, 25, 75, 12, 37, 31, 43. On the way to insert 28, the search meets 37, which is black with two red children.
1225P3137X4350G75
After the flip, red 37 sits under red 25, which breaks rule 3. The path from G to X bends, so two rotations are needed.
1225283137435075
After the color switches and the rotations at 25 and 50, 37 is the root, and 31 and 43 have crossed over. Then 28 goes in under black 31.

The book's RBTree applet leaves every repair to you, so you can replay these examples there by hand. For Possibility 2, for instance, you would select 25 and press R/B, select 12 and press R/B, then select 25 and press RoR. Our re-creation below starts in an automatic mode instead, where each press of Ins carries out the whole top-down algorithm and writes every flip and rotation to a log. Its Manual setting works like the book's applet, if you want to make the repairs yourself.

Deletion

Deletion starts like ordinary binary search tree deletion (Hour 15), including the use of the inorder successor when the node has two children. The trouble comes afterward. Removing a red node changes no black height, but removing a black node leaves every path through it one black node short, which breaks rule 4. The repair involves more cases than insertion, and its color changes and rotations can ripple back up the tree.

The book doesn't work through the deletion algorithm. Many implementations sidestep it with an isDeleted flag, as described in Hour 15. Deleting just marks the node, and the other operations skip marked nodes. The tree's shape never changes, so its balance is never disturbed. That is a sensible trade when deletions are rare, though the dead nodes still take up memory.

Efficiency of Red-Black Trees

Search, insertion, and deletion in a red-black tree all take O(log N) time, and the tree keeps that promise whatever order the data arrives in.

A search visits one node per level. A typical search takes about log₂N comparisons, and it can be shown that no search ever needs more than about 2×log₂N. The factor of 2 comes from the rules. All paths hold the same number of black nodes, and red nodes never come two in a row, so the longest possible path (alternating black and red) is at most twice as long as the shortest (all black).

Comparisons needed to find a key among N items (rounded up)
NPlain BST built from sorted keys (worst)Red-black tree, typicalRed-black tree, never more than
1,0001,0001020
10,00010,0001428
1,000,0001,000,0002040
1,000,000,0001,000,000,0003060

The search routine never looks at colors, so a red-black tree is exactly as fast to search as an ordinary tree of the same shape. The only storage cost is the color itself, one bit of information per node, usually kept in a bool.

Insertion and deletion are slower than in an ordinary tree by a constant factor, because of the flips and rotations. On average an insertion needs about one rotation. Both operations remain O(log N). Since most programs search far more often than they insert or delete, the overall penalty is small, and in return sorted or nearly sorted input can no longer turn the tree into a slow O(N) list.

Implementing the Insertion Process

The book doesn't list code for red-black insertion. It describes what the code needs, and with this hour's rules the list is short.

  • A color in every node. A bool isRed data member is enough.
  • The ordinary insertion routine from Hour 15 as a starting point.
  • A check at each node on the way down. A black node with two red children gets flipped, but the root stays black.
  • After a flip, a red-red check against the parent, repaired by one rotation (outside grandchild) or two (inside grandchild) plus the color switches.
  • At the bottom, the new node inserted as red, followed by the same check.

The rotations need X, P, G, and also G's parent, because the node that ends up on top must be hooked back in where G used to hang. You can track four pointers during the descent, or you can give each node a pointer to its parent.

The code never has to compute a black height. Rule 3 can be checked locally, by comparing a node with its parent, and if the flips and rotations are done correctly, rule 4 takes care of itself. The Workshop applet checks black heights only because a person pressing buttons can easily make a wrong move.

rbInsert.cpp A compact sketch (not from the book): the node and a rotation
class Node
   {
   public:
      int iData;
      // false means black; a new node always starts red
      bool isRed;
      Node* pLeftChild;
      Node* pRightChild;
      Node* pParent;
      Node(int key) : iData(key), isRed(true),
         pLeftChild(NULL), pRightChild(NULL), pParent(NULL) { }
   };

// Hang pNew in the spot where pOld hangs now.
void RBTree::replaceChild(Node* pOld, Node* pNew)
   {
   Node* pAbove = pOld->pParent;
   pNew->pParent = pAbove;
   if(pAbove == NULL)
      pRoot = pNew;
   else if(pAbove->pLeftChild == pOld)
      pAbove->pLeftChild = pNew;
   else
      pAbove->pRightChild = pNew;
   }

// pTop's left child rises, and pTop sinks to the right.
void RBTree::rotateRight(Node* pTop)
   {
   Node* pRiser = pTop->pLeftChild;
   // the crossover subtree moves from pRiser to pTop
   pTop->pLeftChild = pRiser->pRightChild;
   if(pTop->pLeftChild != NULL)
      pTop->pLeftChild->pParent = pTop;
   replaceChild(pTop, pRiser);
   pRiser->pRightChild = pTop;
   pTop->pParent = pRiser;
   }

replaceChild() hangs the rising node where the old top used to hang. If the old top was the root, it updates pRoot, which is what happened when the rotation at 50 made 25 the root. In rotateRight(), pRiser is the left child of pTop. Its right subtree, the crossover subtree, moves across to become pTop's left subtree. Then pRiser takes pTop's place, and pTop becomes its right child. rotateLeft() is the mirror image, with Left and Right exchanged.

rbInsert.cpp A compact sketch (not from the book): the repairs and the insertion loop
// pX and its parent are both red: repair rule 3.
void RBTree::fixRedRed(Node* pX)
   {
   Node* pP = pX->pParent;
   Node* pG = pP->pParent;
   bool pOnLeft = (pG->pLeftChild == pP);
   bool xOnLeft = (pP->pLeftChild == pX);
   pG->isRed = true;
   if(pOnLeft == xOnLeft)
      {
      // outside grandchild: P will end up on top
      pP->isRed = false;
      }
   else
      {
      // inside grandchild: X will end up on top,
      // and a first rotation at P lines it up
      pX->isRed = false;
      if(xOnLeft) rotateRight(pP); else rotateLeft(pP);
      }
   // a rotation at G lifts the new top into G's place
   if(pOnLeft) rotateRight(pG); else rotateLeft(pG);
   }

void RBTree::insert(int key)
   {
   Node* pCurrent = pRoot;
   Node* pAbove = NULL;
   while(pCurrent != NULL)
      {
      // black node with two red children: color flip
      if(!pCurrent->isRed && isRedNode(pCurrent->pLeftChild)
                          && isRedNode(pCurrent->pRightChild))
         {
         // the root keeps its black color
         pCurrent->isRed = (pCurrent != pRoot);
         pCurrent->pLeftChild->isRed = false;
         pCurrent->pRightChild->isRed = false;
         if(isRedNode(pCurrent->pParent))
            fixRedRed(pCurrent);
         }
      pAbove = pCurrent;
      if(key < pCurrent->iData)
         pCurrent = pCurrent->pLeftChild;
      else
         pCurrent = pCurrent->pRightChild;
      }
   // the new red node goes where the search fell off the tree
   Node* pNew = new Node(key);
   pNew->pParent = pAbove;
   if(pAbove == NULL)
      pRoot = pNew;
   else if(key < pAbove->iData)
      pAbove->pLeftChild = pNew;
   else
      pAbove->pRightChild = pNew;
   if(isRedNode(pAbove))
      fixRedRed(pNew);
   pRoot->isRed = false;
   }

fixRedRed() handles every red-red conflict, whether X is a new node or one that a flip just turned red. G always turns red. If X and P hang on the same side, X is an outside grandchild, so P turns black and one rotation at G follows. Otherwise X turns black, a rotation at P lifts X into P's place, and the rotation at G lifts it again. The two bools pick the rotation directions, so the mirror-image cases need no extra code.

insert() is the ordinary descent with the flip test added. After a repair it simply continues down from the same node. That is safe, because the subtrees the search still needs are either below that node or were handed to P and G, which are now its children. The last line keeps the root black, which matters when the very first node goes in. Fed the keys 1 to 1,000 in ascending order, this code builds a tree only 16 levels deep, where a plain binary search tree would be 1,000 levels deep.

Other Balanced Trees

The oldest balanced tree is the AVL tree, named for Adelson-Velskii and Landis, who described it in 1962. Each node stores the difference between the heights of its left and right subtrees, and that difference may never be more than 1. After an insertion, the algorithm walks back up the insertion path, checking each node. Where the difference has reached 2, it rebalances with a single rotation or, when the path bends, a double rotation, much like this hour's outside and inside cases.

Because an AVL tree is so tightly balanced, searches are fast. Insertion is a little slower than in a red-black tree, since it needs a pass down to insert and a pass back up to check, while top-down red-black insertion does all its work on the way down.

The other big family is the multiway tree, whose nodes can have more than two children. You'll meet the simplest one, the 2-3-4 tree, in Hour 19. Its larger relative, the B-tree, packs many keys into each node and is widely used for data stored on disk.

Summary

  • Top-down insertion repairs the tree during a single trip from the root to the insertion point, plus one small fix at the bottom.
  • On the way down, every black node with two red children gets a color flip. A flip never changes black heights; at the root, the root stays black and the whole tree's black height grows by one.
  • A new node is always red, so an insertion can break rule 3 but never rule 4.
  • X is the node that caused a violation, P its parent, and G its grandparent. X is an outside grandchild if it is on the same side of P as P is of G, and an inside grandchild otherwise.
  • If P is black, nothing needs doing. If P is red and X is outside, switch G and P and rotate once at G. If P is red and X is inside, switch G and X, rotate at P, then rotate at G.
  • The flips on the way down guarantee that P never has a red sibling at the bottom, so those three cases are the only ones.
  • A flip can create a red-red conflict halfway down. It is repaired at once with the same moves before the search continues.
  • Deletion is considerably more complicated, and many programs avoid it by marking nodes as deleted.
  • Search, insertion, and deletion are all O(log N). Searching costs the same as in an ordinary tree of the same shape, and an insertion needs about one rotation on average.

Q&A

Why does the algorithm always insert the new node as red?
A red node adds no black node to any path, so rule 4 can't break. At worst rule 3 breaks, which is a local problem between X and its parent that one or two rotations can fix. A new black node would add a black node to some paths and not others, upsetting rule 4 across the tree.
Is a red-black tree perfectly balanced?
No. Some paths may be longer than others, but never more than twice as long. That is enough to guarantee O(log N) operations, and it is much cheaper to maintain than perfect balance.
How does an AVL tree differ from a red-black tree?
An AVL tree stores a height difference in each node instead of a color, and it insists that the two subtrees of every node differ in height by at most one. It is more tightly balanced, so searches can be slightly faster, but each insertion needs a pass down and a pass back up.

Quiz

  1. In the insertion algorithm, what are X, P, and G, and when is X an outside grandchild?

    Answer

    X is the node that caused a rule violation (a new node or a node whose color was just flipped), P is its parent, and G is its grandparent. X is an outside grandchild when it is on the same side of P as P is of G, so both are left children or both are right children.

  2. You have just inserted a red node, and its parent is black. What must you do?

    Answer

    Nothing. A red child of a black node breaks no rule, and a red node doesn't change any black height.

  3. The new node X is red, its parent P is red, and X is an outside grandchild of G. How do you fix the tree?

    Answer

    Switch the color of G, switch the color of P, and rotate with G at the top in the direction that raises X (a right rotation if X and P are left children).

  4. How does the fix change when X is an inside grandchild?

    Answer

    Switch the colors of G and X instead of G and P. Rotate with P at the top to raise X, which straightens the line, and then rotate with G at the top to raise X again. Two rotations are needed.

  5. Why does the algorithm perform color flips on the way down?

    Answer

    So that when the search reaches the insertion point, the new node's parent can't have a red sibling. That rules out the hard case and leaves only the three simple possibilities for the repair at the bottom.

  6. What is the Big O efficiency of searching a red-black tree, and how does it compare with searching an ordinary binary search tree of the same shape?

    Answer

    O(log N), with about log₂N comparisons normally and never more than about 2×log₂N. It is exactly as fast as searching an ordinary tree of the same shape, because the search ignores the colors.

19

Part IV · Trees

Hour 19 2-3-4 Trees

A 2-3-4 tree lets each node hold up to three keys and have up to four children. That extra room lets the tree stay perfectly balanced with nothing more than an occasional node split, which makes it much easier to follow than a red-black tree.

In this hour you'll learn

  • What a multiway tree is, and why the 2-3-4 tree is worth studying
  • How data items and children are arranged in a 2-3-4 node
  • How to search a 2-3-4 tree and where new items go
  • How nodes split, including the root, and why the leaves always stay level
  • How to watch searches, insertions, and splits in the Tree234 Workshop applet

Introduction to 2-3-4 Trees

Every tree you've met so far has been a binary tree, in which each node holds one data item and has at most two children. A 2-3-4 tree is a multiway tree. Each of its nodes can hold up to three data items and can have up to four children.

The 2-3-4 tree earns its place in the book for three reasons. It is a balanced tree, like the red-black tree, but it is easier to understand and to program, because it never needs a rotation. It has a surprising relationship with red-black trees, which Hour 20 reveals. And it is the simplest member of the B-tree family, the multiway trees used for storing data on disk.

What's in a Name?

The 2, 3, and 4 count the links to children a node can have. A node holding one data item always has two children, and it is called a 2-node. A node with two data items has three children (a 3-node), and a node with three data items has four children (a 4-node). In short, a non-leaf node always has exactly one more child than it has data items.

Leaves are the exception, since they have no children at all. A leaf can hold one, two, or three items, but it can't be empty, and no node in a 2-3-4 tree may be empty. Unlike a binary tree, where a node can have a single child, a non-leaf 2-3-4 node never has just one child link.

One more property gives the tree its strength. All the leaves are always on the same level, the bottom one, so a 2-3-4 tree is always perfectly balanced in height. You'll soon see how insertion manages to keep it that way.

503060
A 2-node: one data item, two children.
5070306080
A 3-node: two data items, three children.
50709030608095
A 4-node: three data items, four children.

2-3-4 Tree Organization

Inside a node, the data items are kept in ascending order of their keys, from left to right, in positions numbered 0, 1, and 2. The child links are numbered 0 to 3, and each child covers a range of keys.

  • Child 0 holds keys smaller than key 0.
  • Child 1 holds keys between key 0 and key 1.
  • Child 2 holds keys between key 1 and key 2.
  • Child 3 holds keys larger than key 2.

A node with fewer items simply uses fewer child links, so a 2-node uses only children 0 and 1. This is the binary search tree rule stretched to fit bigger nodes. A binary node splits the keys below it into two ranges, and a 4-node splits them into four. As in the book, assume that no two items have the same key.

2550751020304060708090
The root holds 25, 50, and 75. Its child 0 holds keys below 25, child 1 keys between 25 and 50, child 2 keys between 50 and 75, and child 3 keys above 75.

Searching for a Data Item

Searching works much as it does in a binary search tree. Start at the root. If the key you want isn't one of the node's items, pick the child whose range contains it and move there. Repeat until you find the key, or until you reach a leaf that doesn't hold it, in which case the key isn't in the tree.

Try finding 64 in the tree below. The root holds 50, and 64 is larger, so the search follows child 1. That node holds 60 and 70, and 64 lies between them, so the search follows that node's child 1. The leaf there holds 62, 64, and 66, and the search is over.

50301020406070556264668090
Finding 64: right of 50, then between 60 and 70, then found in the leaf holding 62, 64, and 66. Only three nodes are visited.

Each node visited may take up to three comparisons instead of one, but the tree has fewer levels to pass through. Hour 20 weighs that trade.

Inserting a New Data Item

New data items are always inserted in leaves, on the bottom row of the tree. Putting one into an interior node would give that node an extra item without an extra child, which would break the one-more-child rule.

To insert, you follow the same path a search for the new key would take. If the leaf at the end of the path isn't full, the job is easy. The new item goes into its proper place in key order, and any larger items in the node shift one position to the right to make room. If the search meets a full node anywhere on its way down, that node must be split first, as the next section explains.

30601020405070
Inserting 45: it lies between 30 and 60, so it belongs in child 1, which holds 40 and 50 and has room for one more.
3060102040455070
After the insertion: 50 has shifted one position right, and 45 fills the gap.

Node Splits

A full node holds three items. Call them A, B, and C, in key order. If the node isn't a leaf, it also has four children. The book uses a top-down method, which splits a full node as soon as the insertion's search meets it, before going any further. A split takes five steps.

  1. Create a new, empty node. It becomes a sibling of the node being split, placed to its right.
  2. Move item C into the new node.
  3. Move item B into the parent of the node being split.
  4. Leave item A where it is.
  5. Disconnect the two rightmost children from the node being split and connect them to the new node.

Afterward the old node holds only A and the new node holds only C, with B in the parent between them, marking the boundary between their ranges. The parent's links to children farther right each shift one place to make room for the new sibling. Every key is still in its correct place, and no leaf has changed its depth.

Here is an example. The root holds 50, its left child holds 30, and its right child holds 60, 70, and 80. You want to insert 99. The search goes right from 50 and meets the full node, so it splits that node. 60 (item A) stays, 70 (item B) moves up into the parent beside 50, and 80 (item C) moves into a new node on the right. Then the search continues. Since 99 is larger than 70, it goes to the new node, which has room, and settles in beside 80.

5030607080full
Before: on the way to insert 99, the search meets the full node holding 60, 70, and 80.
507030608099new node
After: 70 has moved up, 60 stayed, 80 went to a new sibling, and 99 then joined 80.

In that example the full node was a leaf, so there were no children to move. When a full interior node splits, its four children are shared out. The two left children stay with A. The two right children move to the new node, as its children 0 and 1. Their ranges still fit, because every key in them is larger than B, one child's keys are smaller than C, and the other's are larger.

Splitting the Root

When the root itself is full at the start of an insertion, there is no parent to receive item B, so the split works a little differently.

  • A new node is created to be the new root, and it becomes the parent of the node being split.
  • A second new node is created as the sibling of the node being split.
  • Item C moves into the new sibling, and item B moves into the new root.
  • Item A stays where it is.
  • The two rightmost children of the old root move to the new sibling.

The tree is now one level taller, and its root holds just one item, B. After the split, the insertion carries on down from the new root as usual.

305070full root10406080
Before: an insertion starts at a full root holding 30, 50, and 70.
50new root30104070new node6080
After: 50 has moved up into a new root, 30 stayed, and 70 went to a new sibling along with the old root's two right children.

Splitting the root is the only way a 2-3-4 tree ever gains a level, and the new level appears at the top. An ordinary binary search tree grows downward, one new leaf at a time, so its branches can end up at very different depths. A 2-3-4 tree grows upward from the root. When a new level appears, every leaf moves one level further from the root at the same moment, and that is why all the leaves always stay on one level.

Splitting on the Way Down

Because an insertion splits every full node it meets on the way down, the parent of a node being split is never full. If it had been full, it would have been split a moment earlier. So there is always room in the parent for item B, a split never spreads upward, and the insertion never needs to back up the tree.

To see all the pieces working together, build a tree from nothing by inserting 60, 30, 80, 40, 20, 50, 70, 90, 10, 55, 95, and 45, in that order. The table traces each step, and the figures after it show the tree at five points along the way.

Building a 2-3-4 tree one insertion at a time
InsertWhat happensLevels
60, 30, 80They go into the root, which is also the only leaf. It is now full: 30, 60, 80.1
40The root is full, so it splits. 60 moves up into a new root, 30 stays, and 80 goes to a new sibling. Then 40 joins 30.2
20Joins the left leaf, which becomes full: 20, 30, 40.2
50The search meets the full leaf 20, 30, 40 and splits it. 30 moves up beside 60, 20 stays, and 40 goes to a new leaf. Then 50 joins 40.2
70, 90Both join the right leaf, which becomes full: 70, 80, 90.2
10Joins 20 in the leftmost leaf.2
55Joins 40 and 50, filling that leaf.2
95The search meets the full leaf 70, 80, 90 and splits it. 80 moves up, which fills the root (30, 60, 80), and 90 goes to a new leaf. Then 95 joins 90.2
45The root is full, so it splits first, and 60 moves up into a new root. Farther down, the search meets the full leaf 40, 50, 55 and splits it too, sending 50 up beside 30. Then 45 joins 40.3
306080full
After 60, 30, and 80: a single full node that is both the root and the only leaf.
60304080
After 40: the root split, putting 60 in a new root, and then 40 joined 30.
306020405080
After 20 and 50: the full leaf 20, 30, 40 split and sent 30 up, and then 50 joined 40.
306080full1020405055full709095
After 95: the root and one leaf are full. Both lie on the path the next key, 45, will take.
603050102040455580709095
After 45: the root split (60 moved up), then the leaf split (50 moved up), and 45 joined 40. All five leaves are on the bottom level.

Notice how the last insertion needed two splits, and each happened as the search reached a full node. When the leaf 40, 50, 55 split, its parent was the old root, which the root split had just cut down to the single item 30, so there was plenty of room for 50.

The Tree234 Workshop Applet

The Tree234 Workshop applet draws a 2-3-4 tree on the screen and lets you search it and add to it. You type keys into its Number box and work it with four buttons: Fill, Find, Ins, and Zoom.

The Fill Button

Fill creates a new tree. Enter the number of data items you want in the Number box, and Fill builds a tree holding that many.

The Find Button

Type a key into the Number box and press Find again and again. Each press moves a red arrow one node farther along the search path, starting at the root, until it reaches the node that holds the key or a leaf that doesn't.

The Ins Button

Ins inserts the key in the Number box. Repeated presses take you through the insertion step by step. You see the arrow move down, every full node on the path split as the arrow reaches it, and finally the new item drop into a leaf.

The Zoom Button

A 2-3-4 tree fans out fast, and a large one won't fit on the screen with all its keys showing. In its normal view, the applet shows the top levels of the tree with their keys. Zoom switches to a zoomed-out view of the whole tree, up to four levels, in which each node is drawn as a small rectangle without its keys. Pressing Zoom again switches back.

Viewing Different Nodes

In the zoomed-out view you can click a node to see its subtree in detail. That way you can examine any part of a big tree, including the lower levels that don't fit in the normal view.

Experiments

The applet is at its most useful when you insert items one at a time and watch nodes fill and split. You'll see that every split pushes one item up a level, and that the tree stays balanced whatever order you insert in, even with keys in ascending order, the input that ruins an ordinary binary search tree. Keep inserting until the root splits and the tree gains a level, and notice that the new level appears at the top while the leaves stay together on the bottom row.

Here is a small thought experiment to go with it. A four-level tree made entirely of 2-nodes has 1 + 2 + 4 + 8 = 15 nodes and so holds 15 items. The same four levels made entirely of 4-nodes have 1 + 4 + 16 + 64 = 85 nodes holding 255 items. So four levels can hold anywhere from 15 to 255 items, and the fuller the nodes, the shallower the tree needed for a given amount of data.

Summary

  • A 2-3-4 tree is a multiway tree whose nodes hold up to three data items and have up to four children.
  • A non-leaf node always has one more child than it has items, making it a 2-node, 3-node, or 4-node. Leaves have no children, and no node may be empty.
  • Items in a node are kept in ascending order, and each child link covers the range of keys between its neighboring items.
  • Searching works like binary tree search, choosing at each node the child whose range contains the key.
  • New items are always inserted in leaves.
  • A full node is split as soon as an insertion meets it: A stays, B moves up to the parent, C moves to a new right sibling, and the two rightmost children go with C.
  • A full root splits into a new root holding B and two children, which makes the tree one level taller at the top.
  • Because the tree only grows at the top, all leaves stay on the same level, and because full nodes are split on the way down, a split never has to spread upward.

Q&A

Why must new items go into leaves?
A non-leaf node must have exactly one more child than it has items. Adding an item to an interior node would require an extra child as well, and there is nothing to put there. A leaf has no children to worry about, so it can take a new item whenever it has room.
Could one split set off a chain of splits all the way to the root?
Not with the top-down method used here. Every full node on the path is split before the search passes it, so the parent of a node being split always has room for item B. An approach that splits only when a leaf overflows could trigger such a chain, and it would have to climb back up the tree.
Is a 2-3-4 tree perfectly balanced?
In height, yes. Every leaf is on the same level. The nodes themselves can hold different numbers of items, though, so some subtrees may contain more data than others at the same depth.

Quiz

  1. What is the largest number of data items a 2-3-4 node can hold, and the largest number of children it can have?

    Answer

    Three data items and four children.

  2. A non-leaf node holds two data items. How many children does it have, and what is the general rule?

    Answer

    Three children. A non-leaf node always has one more child than it has data items.

  3. Where in the tree is a new data item always inserted?

    Answer

    In a leaf, on the bottom row of the tree.

  4. A full node holding items A, B, and C is split. What happens to each item, and to the node's children?

    Answer

    A stays in the node. B moves up into the parent. C moves into a new node created as the right-hand sibling. The two rightmost children are disconnected and become the new node's children.

  5. What happens when the root is full at the start of an insertion?

    Answer

    It is split using two new nodes: a new root, which receives B, and a new sibling, which receives C and the old root's two rightmost children. A stays in the old root. The tree becomes one level taller.

  6. Why do all the leaves of a 2-3-4 tree stay on the same level?

    Answer

    The tree gains height only when the root splits, which adds a level at the top and moves every leaf one level farther from the root at once. Other splits add nodes sideways, next to the node being split, without changing any depth.

20

Part IV · Trees

Hour 20 Implementing 2-3-4 Trees

This hour turns the 2-3-4 tree of Hour 19 into a working C++ program. Then it shows that 2-3-4 trees and red-black trees are two views of the same structure, and it weighs the two against each other for speed and storage.

In this hour you'll learn

  • How the DataItem, Node, and Tree234 classes of tree234.cpp work together
  • How the insert() and split() routines carry out the steps from Hour 19
  • How any 2-3-4 tree can be turned into an equivalent red-black tree
  • Why splitting a node and flipping colors are really the same operation
  • How 2-3-4 trees compare with red-black trees in speed and in memory use

Implementing a 2-3-4 Tree in C++

The book's program for this hour, tree234.cpp, is built from three classes. DataItem holds one data item. Node holds up to three items and links to up to four children. Tree234 holds the root and carries out searches, insertions, and splits. A main() function lets you show the tree, insert items, and search for them from the keyboard. The listings below are our own reconstruction in the book's style, and they keep the book's class and function names so you can match them with the original.

The DataItem Class

DataItem is about as simple as a class can be. It stores a single double called dData, which serves as the key. In a real program the object would carry a whole record, such as a name and an address, along with its key. displayItem() prints the item as a slash followed by the key, such as /27.

tree234.cpp The DataItem class
#include <iostream>
using namespace std;

class DataItem
   {
   public:
      double dData;
      DataItem(double dd) : dData(dd)
         { }
      // prints the item as "/27"
      void displayItem()
         { cout << "/" << dData; }
   };

The Node Class

A Node stores pointers in two arrays. childArray has room for ORDER pointers to child nodes, and itemArray has room for ORDER-1 pointers to data items. With ORDER set to 4, that makes four child slots and three item slots. numItems records how many items are in use, and pParent points back to the node's parent (it is NULL for the root). Unused slots hold NULL.

tree234.cpp The Node class
class Node
   {
   private:
      static const int ORDER = 4;
      int numItems;
      Node* pParent;
      // room for four child links and three items
      Node* childArray[ORDER];
      DataItem* itemArray[ORDER-1];
   public:
      Node() : numItems(0), pParent(NULL)
         {
         for(int j = 0; j < ORDER; j++)
            childArray[j] = NULL;
         for(int j = 0; j < ORDER-1; j++)
            itemArray[j] = NULL;
         }
      // put pChild in slot childNum and give it a link back up
      void connectChild(int childNum, Node* pChild)
         {
         childArray[childNum] = pChild;
         if(pChild != NULL)
            pChild->pParent = this;
         }
      // empty slot childNum and hand back what was there
      Node* disconnectChild(int childNum)
         {
         Node* pOld = childArray[childNum];
         childArray[childNum] = NULL;
         return pOld;
         }
      Node* getChild(int childNum)  { return childArray[childNum]; }
      Node* getParent()             { return pParent; }
      bool isLeaf()                 { return childArray[0] == NULL; }
      int getNumItems()             { return numItems; }
      DataItem* getItem(int index)  { return itemArray[index]; }
      bool isFull()                 { return numItems == ORDER-1; }
      int findItem(double key);
      int insertItem(DataItem* pNewItem);
      DataItem* removeItem();
      void displayNode();
   };

The short member functions do the bookkeeping. connectChild() puts a child pointer into a given slot and, if the child exists, sets the child's pParent to point back at this node. disconnectChild() empties a slot and returns the pointer that was there, so the caller can attach that child somewhere else. isLeaf() checks whether childArray[0] is NULL, since any node with children has a child 0. isFull() checks whether all three item slots are in use. The rest are plain accessors.

tree234.cpp The longer Node member functions
// Returns the index of the item with this key, or -1.
int Node::findItem(double key)
   {
   for(int j = 0; j < numItems; j++)
      if(itemArray[j]->dData == key)
         return j;
   return -1;
   }

// Adds an item to a node that is not full, keeping the keys in
// ascending order. Returns the index where the new item lands.
int Node::insertItem(DataItem* pNewItem)
   {
   double newKey = pNewItem->dData;
   int j = numItems - 1;
   // every larger item moves one cell to the right
   while(j >= 0 && itemArray[j]->dData > newKey)
      {
      itemArray[j+1] = itemArray[j];
      j--;
      }
   // the gap is just right of the last smaller item
   itemArray[j+1] = pNewItem;
   numItems++;
   return j+1;
   }

// Takes the largest (rightmost) item out of the node.
DataItem* Node::removeItem()
   {
   numItems--;
   DataItem* pLargest = itemArray[numItems];
   itemArray[numItems] = NULL;
   return pLargest;
   }

// Prints the node as "/27/35/".
void Node::displayNode()
   {
   for(int j = 0; j < numItems; j++)
      itemArray[j]->displayItem();
   cout << "/" << endl;
   }

findItem() compares the key with each item in use and returns that item's index, or −1 if the key isn't there. A node holds at most three items, so this never takes more than three comparisons.

insertItem() adds an item to a node that isn't full. It starts with the rightmost item in use and works left, sliding each item that is larger than the new key one slot to the right. When it reaches a smaller item, or runs off the left end, the gap it has opened is exactly where the new item belongs. It returns that position, which split() will need. This is the same shifting you saw in Hour 19 when 45 joined 40 and 50.

removeItem() takes the rightmost item, which is always the largest, out of the node and returns it. split() calls it twice, to lift out C and then B. displayNode() prints the items between slashes, as in /27/35/.

The Tree234 Class

Tree234 has a single data member, pRoot. Its constructor creates an empty node to serve as the root. That is the only empty node the tree ever contains, and only until the first item arrives.

tree234.cpp The Tree234 class, find(), and getNextChild()
class Tree234
   {
   private:
      Node* pRoot;
      void split(Node* pThisNode);
      Node* getNextChild(Node* pNode, double theValue);
      void recDisplayTree(Node* pThisNode, int level, int childNumber);
   public:
      // an empty tree is a single empty node
      Tree234() : pRoot(new Node)
         { }
      int find(double key);
      void insert(double dValue);
      void displayTree()
         { recDisplayTree(pRoot, 0, 0); }
   };

// Returns the key's index inside the node that holds it,
// or -1 if the key is not in the tree.
int Tree234::find(double key)
   {
   Node* pCurNode = pRoot;
   while(true)
      {
      int index = pCurNode->findItem(key);
      if(index != -1)
         return index;
      // a leaf without the key: nowhere left to look
      if(pCurNode->isLeaf())
         return -1;
      pCurNode = getNextChild(pCurNode, key);
      }
   }

// Chooses the child of pNode whose range covers theValue.
Node* Tree234::getNextChild(Node* pNode, double theValue)
   {
   int j = 0;
   // step past every key that is smaller than theValue
   while(j < pNode->getNumItems() && pNode->getItem(j)->dData < theValue)
      j++;
   return pNode->getChild(j);
   }

find() starts at the root and asks each node whether it holds the key. If it does, find() returns the item's index within that node. If it doesn't and the node is a leaf, the search has failed, and find() returns −1. Otherwise getNextChild() picks the next node to visit.

getNextChild() counts the keys in the node that are smaller than the value, and that count is the number of the child to follow. A value smaller than every key leads to child 0, a value larger than all of them leads to the last child, and anything in between leads to the child whose range it falls in.

tree234.cpp Inserting and splitting
void Tree234::insert(double dValue)
   {
   Node* pCurNode = pRoot;
   while(true)
      {
      // a full node on the path is split before we go on
      if(pCurNode->isFull())
         {
         split(pCurNode);
         // item B is now in the parent: choose again from there
         pCurNode = pCurNode->getParent();
         }
      // on the bottom row: this leaf has room
      if(pCurNode->isLeaf())
         break;
      pCurNode = getNextChild(pCurNode, dValue);
      }
   pCurNode->insertItem(new DataItem(dValue));
   }

// Splits a full node that holds items A, B and C.
void Tree234::split(Node* pThisNode)
   {
   // C and then B come off the right end; A stays put
   DataItem* pItemC = pThisNode->removeItem();
   DataItem* pItemB = pThisNode->removeItem();
   // the two rightmost children go to the new sibling
   Node* pChild2 = pThisNode->disconnectChild(2);
   Node* pChild3 = pThisNode->disconnectChild(3);
   Node* pNewRight = new Node;
   pNewRight->insertItem(pItemC);
   pNewRight->connectChild(0, pChild2);
   pNewRight->connectChild(1, pChild3);
   // a root that splits gets a new, empty parent
   if(pThisNode == pRoot)
      {
      pRoot = new Node;
      pRoot->connectChild(0, pThisNode);
      }
   Node* pParent = pThisNode->getParent();
   // B moves up; children to the right of its slot shift right
   int itemIndex = pParent->insertItem(pItemB);
   for(int j = pParent->getNumItems() - 1; j > itemIndex; j--)
      pParent->connectChild(j+1, pParent->disconnectChild(j));
   // the new sibling hangs just to the right of B
   pParent->connectChild(itemIndex+1, pNewRight);
   }

insert() walks down from the root. At each node it first asks whether the node is full. If so, it splits the node and climbs to the parent. After the split, the node it was standing on holds only A, and the new value might belong under C in the new sibling instead, so it lets the parent, which now holds B, choose the way again. If the node is a leaf, the loop ends. Otherwise getNextChild() takes the search one level down. Since every full node is split the moment it is reached, the leaf at the end always has room, and insertItem() puts the new item there.

split() carries out the five steps of Hour 19, though not in the same order.

  • The two calls to removeItem() lift C and then B off the right end of the node. A stays where it is (step 4).
  • disconnectChild(2) and disconnectChild(3) detach the two rightmost children. For a leaf these are just NULL.
  • A new node, pNewRight, is created (step 1). It receives C (step 2), and the two detached children become its children 0 and 1 (step 5).
  • If the node being split is the root, a new empty root is created and the old root is connected as its child 0. From here on, the parent is simply getParent().
  • B is inserted into the parent (step 3), and insertItem() reports the position where it landed. Every child link to the right of that position moves one slot right, and pNewRight is connected in the gap, just right of B.

Those last moves matter. Suppose the parent holds 40 and 80, and its middle child, holding 50, 60, and 70, splits. B (60) lands at position 1, between 40 and 80. The parent's child 2 (keys above 80) slides over to slot 3, and the new node holding 70 goes into slot 2, between 60 and 80, where its keys belong.

The tree234.cpp Program

The last pieces are the display routine and main(). displayTree() starts recDisplayTree() at the root, on level 0. recDisplayTree() prints a node's level, its child number within its parent, and its items, and then calls itself for each child, one level deeper. Because each node is printed before its children, the output follows a preorder traversal (Hour 16).

tree234.cpp Displaying the tree, and main()
// Prints a node, then each of its children one level deeper.
void Tree234::recDisplayTree(Node* pThisNode, int level, int childNumber)
   {
   cout << "level=" << level << " child=" << childNumber << " ";
   pThisNode->displayNode();
   if(pThisNode->isLeaf())
      return;
   for(int j = 0; j <= pThisNode->getNumItems(); j++)
      recDisplayTree(pThisNode->getChild(j), level+1, j);
   }

int main()
   {
   Tree234 theTree;
   // start with five items already in place
   theTree.insert(50);
   theTree.insert(40);
   theTree.insert(60);
   theTree.insert(30);
   theTree.insert(70);
   char choice;
   double value;
   while(true)
      {
      cout << "Type s (show), i (insert) or f (find): ";
      if(!(cin >> choice))
         break;
      if(choice == 's')
         theTree.displayTree();
      else if(choice == 'i' || choice == 'f')
         {
         cout << "Value: ";
         cin >> value;
         if(choice == 'i')
            theTree.insert(value);
         else if(theTree.find(value) != -1)
            cout << "Found " << value << endl;
         else
            cout << "Could not find " << value << endl;
         }
      else
         cout << "Unknown command" << endl;
      }
   return 0;
   }

main() inserts five items and then waits for commands: s shows the tree, i inserts a value, and f finds one. You can trace the five insertions by hand. First 50, 40, and 60 fill the root, which is also a leaf. When 30 arrives the root is full, so it splits: 50 moves up into a new root, 40 stays, and 60 goes to a new node. Then 30 joins 40, and finally 70 joins 60.

5030406070
The tree that main() builds from 50, 40, 60, 30, and 70: a root holding 50 above two leaves.

Here is a short session. The first s shows that tree. Inserting 80 fills the right leaf, and inserting 90 then finds that leaf full and splits it: 70 moves up beside 50, 60 stays, and 80 goes to a new leaf, where 90 joins it.

Type s (show), i (insert) or f (find): s
level=0 child=0 /50/
level=1 child=0 /30/40/
level=1 child=1 /60/70/
Type s (show), i (insert) or f (find): i
Value: 80
Type s (show), i (insert) or f (find): i
Value: 90
Type s (show), i (insert) or f (find): s
level=0 child=0 /50/70/
level=1 child=0 /30/40/
level=1 child=1 /60/
level=1 child=2 /80/90/
Type s (show), i (insert) or f (find): f
Value: 60
Found 60
Type s (show), i (insert) or f (find): f
Value: 65
Could not find 65

2-3-4 Trees and Red-Black Trees

A 2-3-4 tree and a red-black tree look completely different, yet they are equivalent. Any 2-3-4 tree can be turned into a red-black tree, any red-black tree can be turned back into a 2-3-4 tree, and the operations that keep the two balanced correspond as well. Historically, the 2-3-4 tree came first, and the red-black tree was developed from it. Rudolf Bayer introduced red-black trees in 1972, under the name symmetric binary B-trees.

Transformation from 2-3-4 to Red-Black

Three rules convert the nodes one at a time.

  1. Transform any 2-node into a black node.
  2. Transform any 3-node into a black parent P and a red child C. C can be either the left or the right child, so every 3-node has two possible red-black forms.
  3. Transform any 4-node into a black parent (the middle item) and two red children (the other two items).

In each case the node's own children, converted by the same rules, hang below the new nodes in key order. In the figures below, every child is a 2-node, so each becomes a single black node.

503060
A 2-node holding 50 ...
305060
... becomes a single black node.
5070306080
A 3-node holding 50 and 70 ...
3050607080
... becomes black 70 with red 50 on the left ...
3050607080
... or black 50 with red 70 on the right.
50709030608095
A 4-node holding 50, 70, and 90 ...
30506070809095
... becomes black 70, the middle item, with red children 50 and 90.

Convert every node of a 2-3-4 tree this way, and the result is always a legal red-black tree. Each 2-3-4 node becomes exactly one black node plus at most two red children, so no red node ever has a red child, and the root is black. Every level of the 2-3-4 tree contributes exactly one black node to any path through it. All 2-3-4 leaves are on the same level, so every path from the root to a null child passes through the same number of black nodes, and rule 4 holds. Going the other way is just as easy. Merge each black node with its red children, and you have the 2-3-4 nodes back.

5030406070
The tree built by tree234.cpp: a 2-node root above two 3-nodes.
3040506070
Its red-black equivalent. Each 3-node became a black parent with a red left child, and every path holds two black nodes.

Our re-creation of the Tree234 applet can show this conversion as you work. When its Show as red-black checkbox is ticked, it also draws the red-black tree that is equivalent to the 2-3-4 tree on screen.

Operational Equivalence

The match goes beyond shapes. The operations that keep each tree in balance correspond too.

Start with splits. In red-black form, a 4-node is a black parent with two red children, which is exactly the pattern a color flip looks for. Splitting a 4-node in the 2-3-4 tree is the same as flipping those colors in the red-black tree. After the flip, the parent is red, which means it now belongs with the node above it. It has moved up, just as item B moves up during a split. Its two children, now black, stand on their own as two separate 2-nodes, the A and C of the split.

5030607080full
2-3-4 view: on the way to insert 99, the full node holding 60, 70, and 80 must split.
507030608099
After the split, 70 has moved up beside 50, and 99 has joined 80.
30506070flip80
Red-black view of the same tree: the 4-node is black 70 with red children, so the insertion flips it.
305060708099
After the flip and the insertion of 99: red 70 now pairs with black 50 as a 3-node, and black 80 with red 99 is the new 3-node below.

Rotations have a counterpart as well. A 3-node has two red-black forms, with its red child on the left or on the right. A rotation, together with swapping the two nodes' colors, turns one form into the other, as in the two 3-node pictures above, while the 2-3-4 tree the drawing stands for doesn't change at all.

This explains the repairs of Hour 18. In Possibility 2 there, black 25 with red 12 and red 6 hanging in a line below it was really a single 2-3-4 node holding 6, 12, and 25, drawn lopsided. The rotation and the color switches redrew it in proper 4-node form, as black 12 with red children 6 and 25.

Efficiency of 2-3-4 Trees

With the equivalence in mind, the two trees are easy to compare. They are balanced in the same way, so the real questions are how fast each one runs and how much memory each one needs.

Speed

In a red-black tree, a search visits one node on each level, and a balanced binary tree has about log₂N levels. A 2-3-4 tree is shorter, because each node holds more data. If every node were full, it would have about log₄N levels, which is only half of log₂N. On the other hand, the search must compare the key with up to three items in each node instead of one.

Levels a search passes through (rounded)
NRed-black tree (about log₂N)2-3-4 tree with full nodes (about log₄N)
1,000105
1,000,0002010
1,000,000,0003015

The two effects roughly cancel. Searching a 2-3-4 tree is O(log N) and takes about as long as searching a red-black tree. Insertion is O(log N) as well, and the only restructuring it ever does is splitting nodes.

Storage Requirements

Every 2-3-4 node sets aside room for three data items and four child pointers, whether it uses them or not. Nodes are often far from full, so much of that space goes to waste. A 2-node uses only one of its three item slots and two of its four child pointers. A tree made up entirely of 2-nodes would leave two-thirds of its item slots and half of its child pointers empty.

How much of a 2-3-4 node is in use
Node typeItem slots usedChild pointers used (non-leaf)
2-node1 of 32 of 4
3-node2 of 33 of 4
4-node3 of 34 of 4

A red-black node, by contrast, holds exactly one item, two child pointers, and a color, and it uses all of them, apart from the null child pointers at the bottom that every binary tree has. So red-black trees make much better use of memory. The 2-3-4 tree's advantage is simplicity. It needs no rotations and no color cases, only splits, which makes it easier to program.

The 2-3-4 tree is also the simplest of the B-trees. B-trees whose nodes have hundreds of children are used when data is stored on disk. There, reading a node means reading a whole block from the disk, which takes far longer than comparing keys in memory. A very short, very bushy tree keeps the number of block reads small, and a few empty slots per node hardly matter.

Summary

  • The tree234.cpp program uses three classes: DataItem holds one key, Node holds up to three item pointers and four child pointers, and Tree234 holds the root and does the searching, inserting, and splitting.
  • Node::insertItem() slides larger items right to keep the keys in order and returns the new item's position. removeItem() takes out the largest item.
  • Tree234::insert() splits every full node on its way down, returns to the parent after each split to choose the path again, and finally inserts the new item in a leaf.
  • split() removes C and B, gives C and the two rightmost children to a new sibling, inserts B into the parent (a brand-new root if necessary), and shifts the parent's child links to make room.
  • Any 2-3-4 tree converts to a red-black tree. A 2-node becomes a black node, a 3-node a black parent with one red child on either side, and a 4-node a black parent with two red children.
  • Splitting a 4-node corresponds to a color flip, and switching a 3-node's red child from one side to the other corresponds to a rotation.
  • Searching takes about the same time, O(log N), in both trees. The 2-3-4 tree has fewer levels but more comparisons per node.
  • A 2-3-4 tree wastes memory in partly empty nodes, while a red-black tree uses memory efficiently. The 2-3-4 tree is easier to program, and its larger relatives, the B-trees, are the standard way to organize data on disk.

Q&A

Why does the Node class store pointers to DataItem objects rather than the objects themselves?
Items move around a lot in a 2-3-4 tree. They shift within a node during insertions and jump to other nodes during splits. Moving a pointer costs the same however large the record is, and an empty slot is easy to mark with NULL.
Which should I use, a 2-3-4 tree or a red-black tree?
For data in memory, they search equally fast. The red-black tree uses less memory, and the 2-3-4 tree is easier to understand and to program. For data on disk, the 2-3-4 tree's larger relative, the B-tree, is the usual choice.
How do I turn a red-black tree back into a 2-3-4 tree?
Merge each black node with its red children, if it has any, into a single node. A black node on its own becomes a 2-node, a black node with one red child becomes a 3-node, and a black node with two red children becomes a 4-node.

Quiz

  1. In the Node class, what do childArray and itemArray hold, and how many slots does each have?

    Answer

    childArray holds pointers to the node's children and has ORDER (4) slots. itemArray holds pointers to the node's data items and has ORDER-1 (3) slots.

  2. In split(), what happens to items B and C?

    Answer

    Both are removed from the node being split. C goes into a new node that becomes the right-hand sibling, along with the node's two rightmost children. B is inserted into the parent, which is a newly created root if the node being split was the root.

  3. What does a 3-node become in a red-black tree?

    Answer

    A black parent with one red child. The red child can be either the left or the right child, so there are two equivalent forms.

  4. Which red-black tree operation corresponds to splitting a 4-node in a 2-3-4 tree?

    Answer

    A color flip. The black parent with two red children becomes a red parent with two black children, and the red parent joins the node above, just as item B moves up in a split.

  5. Why can a 2-3-4 tree waste a lot of memory?

    Answer

    Every node reserves space for three items and four child pointers, but many nodes aren't full. A 2-node leaves two of its three item slots and two of its four child pointers empty.

  6. How does searching a 2-3-4 tree compare in speed with searching a red-black tree?

    Answer

    They are about the same, both O(log N). The 2-3-4 tree has fewer levels (as few as half as many), but each node may need up to three comparisons instead of one.

21

Part V · Hash Tables

Hour 21 Hash Tables

A hash table works out where an item must be and goes straight there, so insertion and searching take close to one step. This hour builds the idea from the ground up, deals with keys that collide, and ends with a working C++ table that uses linear probing.

In this hour you'll learn

  • Why hash tables are fast, and where they fall short
  • How a word becomes a number, and a number becomes an array index
  • What a collision is, and the two families of solutions
  • How linear probing inserts, finds and deletes items
  • Why clustering and a high load factor slow a table down
  • How the program hash.cpp works, and how to move a table into a bigger array

Most structures in this book find an item by searching for it, halving an ordered array or walking down a tree. A hash table skips the search. It feeds the key into a quick calculation that names the array cell where the item belongs. Insertion and searching therefore take close to O(1) time, the same few steps whether the table holds a hundred items or a million, and the code is much simpler than a balanced tree's.

The drawbacks are real, though. A hash table is built on an array, and an array is hard to enlarge once created. Some kinds of hash table slow down badly when the array gets too full, so you need a fair idea of how many items you will store. And there is no convenient way to visit the items in key order. If you don't need ordered traversal and can predict the size of your data, a hash table is very hard to beat.

Introduction to Hashing

Hashing means turning a key into an array index. Lafore starts with keys that already are indices, then moves to English words, where the conversion takes some thought.

Employee Numbers as Keys

Picture a small company with about 1,000 employees, numbered in order of hiring from 1 up to 1,000. The records of people who leave are kept on file, so no number is ever reused and the sequence has no gaps.

Store each record in the array cell whose index equals the employee number. Employee 72's record sits in cell 72, so fetching it is a single array access, databaseArray[72], with no searching at all. Adding a new hire is just as quick, because the record goes into the cell for the next unused number. Both operations are O(1).

This works only because the keys are unusually tidy. They are consecutive, they have no gaps, and the largest one is known, which fixes the size of the array. Real-world keys are rarely so well behaved.

A Dictionary

Now suppose you want a 50,000-word English dictionary in memory, with every word in its own cell of a 50,000-cell array, so that any word can be looked up in one step. Which cell does giraffe belong in? Before anything else, you need a rule that turns a word into a number.

Converting Words to Numbers

Computers already store letters as codes (in ASCII, a is 97 and z is 122), but a simpler code is handier here. Let a be 1, b be 2 and so on up to z at 26, with 0 for a blank. That gives 27 symbols, ignoring capital letters. There are two obvious ways to combine a word's codes into one number.

Add the digits. Adding the codes turns cats into c (3) + a (1) + t (20) + s (19) = 43, so cats would live in cell 43. The trouble is the range. If no word has more than 10 letters, no total can exceed 26 × 10 = 260 (for zzzzzzzzzz), so every word lands in cells 0 to 260. That crams 50,000 words into 261 cells, about 192 words per cell, which is far too crowded to be useful.

Multiply by powers. The opposite idea gives every word a number of its own, copying the way decimal numbers work. The number 7,546 means 7 × 10³ + 5 × 10² + 4 × 10¹ + 6 × 10⁰. Treat each letter as one digit of a base-27 number instead, and multiply it by a power of 27, as the table shows for cats.

Turning cats into a base-27 number
LetterCodePowerPower's valueCode × value
c327³19,68359,049
a127²729729
t2027¹27540
s1927⁰119
Total60,337

Now no two words share a number, since every different string of letters is a different base-27 number. The catch is size. The string zzzzzzzzzz works out to 27¹⁰ − 1, which is 205,891,132,094,648, more than 205 trillion. No memory can hold an array that big, and nearly all of its cells would be reserved for nonsense such as qxvkzjrwpt. Adding made the range far too small, and multiplying makes it absurdly large.

Hashing

The way out is to squeeze the huge range of base-27 numbers into a range that fits a reasonable array. For the dictionary, a good size is about twice the number of words, so 100,000 cells for 50,000 words.

The squeezing tool is the modulo operator, %, which gives the remainder after a division. To squeeze the numbers 0 to 199 into the range 0 to 9, write smallNumber = largeNumber % 10;. The remainder after dividing by 10 is always between 0 and 9, so 157 becomes 7 and 43 becomes 3. The size of the small range equals the divisor.

The dictionary does the same with arrayIndex = hugeNumber % arraySize;. The word table has the base-27 number 10,650,290, and 10,650,290 % 100,000 = 50,290, so table goes in cell 50,290. Even zzzzzzzzzz lands in cell 94,648. Only the 50,000 real words are ever hashed, so no cell is wasted on nonsense, and about half the cells stay empty as slack.

base-27 digits% arraySizewordtablehuge number10,650,290array index50,290
Hashing a word. Its letters become the digits of a huge base-27 number, and the remainder after dividing by the array size (100,000 here) is the cell index.

Collisions

Squeezing has a price. Countless huge numbers leave the same remainder, so two different words can hash to the same index. Lafore pictures inserting the word melioration and finding that its hash value is the very cell already holding cats. That is a collision. With vastly more possible words than cells, collisions cannot be prevented, only handled well. There are two broad approaches.

  • Open addressing. The array has plenty of empty cells, so put the item in some other empty cell. There are three standard ways to choose that cell: linear probing, quadratic probing and double hashing.
  • Separate chaining. Make every cell hold a linked list, and add each item to the list at its hash index, however many items that list already holds.
collisioncell already fullopen addressinguse another cellseparate chaininga list per cellHour 23linear probingthis hourquadratic probingHour 22double hashingHour 22
The two families of collision handling. Open addressing looks for another cell in the same array, in one of three ways; separate chaining keeps a list at every cell.

Linear probing fills the rest of this hour. Quadratic probing and double hashing follow in Hour 22, and separate chaining in Hour 23.

Linear Probing

In linear probing you step along the array one cell at a time, looking for a vacant cell. If a key hashes to cell 5,421 and that cell is taken, you try 5,422, then 5,423, and so on. Each look at a cell is called a probe, and since the probes march along in a straight line, the method is called linear probing. A search that runs past the last cell wraps around to cell 0 and carries on.

The Hash Workshop Applet

Lafore's Hash Workshop applet shows this in action. It starts with a 60-cell array partly filled with random keys from 0 to 999, and it hashes with key % arraySize, so the key 472 goes to cell 52 (472 is 7 × 60 + 52). A red arrow marks the cell being examined. The buttons are New (which asks for the table size, the number of items and whether duplicates are allowed), Fill, Ins, Find and Del.

Inserting. The arrow jumps to the key's hash index. If that cell is empty, the key goes there after one probe. Otherwise the arrow moves forward cell by cell to the first empty one, and the key goes there. The number of cells visited is the probe length.

Finding. Find follows the same path from the hash index until it meets the key or reaches an empty cell. An empty cell proves the key is absent, because the key's own insertion would have followed this same path and could never have gone past an empty cell.

Deleting. You cannot simply empty the cell. In the 7-cell tables below, the keys 15, 22 and 29 all hash to cell 1 (each leaves a remainder of 1 when divided by 7), so they occupy cells 1, 2 and 3. If deleting 22 left cell 2 empty, a later search for 29 would stop at that hole and report 29 missing.

So a deleted item is replaced by a special marker, shown as Del in the applet and as a DataItem with key −1 in the C++ code. Insertion treats a marked cell as free and may reuse it. Find treats it as occupied and steps over it.

0151homedel2skip293found4556123
With a marker in cell 2, a search for 29 steps over it and finds 29 in cell 3.
0151home2stops293455612
Had cell 2 simply been emptied, the search would stop at the hole and wrongly report 29 as missing.

Markers have a cost. After many deletions the table is littered with them, and every search must walk past them, so probes lengthen even though fewer real items remain. Rehashing, described at the end of this hour, clears them out.

Our re-creation below works the same way. It shows each probe sequence and the current load factor, a measure of fullness explained shortly.

Duplicates Allowed?

The applet lets you allow duplicate keys, but they complicate things. To report every copy of a key, Find would have to keep going past the first match to the end of the cluster, which slows every search, even for keys that have no copies. Deleting all copies of a key has the same problem. So hash tables usually forbid duplicates, typically by calling Find before each insertion and refusing a key that is already there. The insert() function later in this hour skips that check to stay short, leaving it to the caller.

Clustering

Keep filling the applet's table and the occupied cells bunch into runs called clusters. A key that hashes anywhere into a cluster must step along to its end, and then it takes the first empty cell beyond, making the cluster longer. So a cluster is a target that grows every time it catches a key.

012211834285516home997146874916710lands111213131415164017181920420212212345
Primary clustering in a 23-cell table. The key 167 hashes to cell 6 (167 % 23), inside the cluster in cells 5 to 9, so it probes five cells and lands in cell 10, making the cluster longer.

Before 167 arrived, every key that hashed to any of cells 5 through 10 was headed for cell 10. That is 6 of the 23 possible home cells, so roughly one new key in four would have landed there. A lone empty cell such as 15, with an empty cell in front of it, only catches keys that hash to 15 itself, about one key in 23.

This effect is called primary clustering. Big clusters grow faster than small ones, and neighboring clusters merge into bigger ones. If an item lands in cell 4, the small cluster in cells 2 and 3 fuses with the big one into a single run from cell 2 to cell 10. Long clusters mean long probes, both for insertions and for searches that end in failure.

Load Factor

A table of 10,000 cells holding 6,667 items has a load factor of 0.6667 (in code, loadFactor = nItems / arraySize; computed in floating point). The clustering picture shows 11 items in 23 cells, a load factor of about 0.48.

With linear probing, clusters stay modest while the load factor is below about one-half. Past two-thirds they lengthen and merge, and probe lengths shoot up. So keep a linear-probing table no more than one-half to two-thirds full, which is why the dictionary got twice as many cells as words. Hour 22 supplies the exact numbers.

C++ Code for a Linear Probe Hash Table

The program hash.cpp puts these ideas into C++. It fills a hash table with random keys and then lets you show, insert, delete and find items from the keyboard. The table is a vector of pointers to DataItem objects, with NULL marking an empty cell.

Classes

A DataItem holds one int key, iData. A real record would carry more fields, but the key is all the hash table looks at. HashTable holds the vector, its size, and pNonItem, a pointer to the single item that marks deleted cells.

hash.cpp The item class and the hash table's data
#include <iostream>
#include <vector>
#include <cstdlib>
#include <ctime>
using namespace std;

class DataItem
{
public:
   // The key. A real record would carry more fields.
   int iData;
   DataItem(int ii) : iData(ii)
   { }
};

class HashTable
{
private:
   // Each cell points to an item, or is NULL when empty.
   vector<DataItem*> hashArray;
   int arraySize;
   // One shared stand-in for every deleted item.
   DataItem* pNonItem;
public:
   HashTable(int size) : arraySize(size)
   {
      // Every cell starts out empty.
      hashArray.resize(arraySize);
      for(int j = 0; j < arraySize; j++)
         hashArray[j] = NULL;
      // Real keys are never negative, so -1 can mean "deleted".
      pNonItem = new DataItem(-1);
   }

   int hashFunc(int key)
   {
      return key % arraySize;
   }

   void insert(DataItem* pItem);
   DataItem* find(int key);
   DataItem* remove(int key);
   void displayTable();
};

The constructor sets every cell to NULL and creates the marker with key −1, which is safe because real keys are never negative. Every deleted cell will point at this one shared marker, so a deletion never has to create anything. The hash function is the plain modulo from earlier in the hour. The other member functions are defined outside the class.

hash.cpp Inserting with linear probing
// Put the item in the first free cell at or after its hash index.
// The table must not be full, or this loop never ends.
void HashTable::insert(DataItem* pItem)
{
   int hashVal = hashFunc(pItem->iData);
   // Walk past cells that hold live items. A NULL cell or a
   // deleted marker (key -1) is free, so the loop stops there.
   while(hashArray[hashVal] != NULL &&
         hashArray[hashVal]->iData != -1)
   {
      // Move one cell along; after the last cell, go to cell 0.
      hashVal = (hashVal + 1) % arraySize;
   }
   hashArray[hashVal] = pItem;
}

insert() hashes the key, then walks forward as long as the current cell holds a live item, meaning the cell is not NULL and its key is not −1. It stops at the first empty or deleted cell and stores the item there.

hash.cpp Finding and removing items
DataItem* HashTable::find(int key)
{
   int hashVal = hashFunc(key);
   // Only an empty cell ends the search; markers are stepped over.
   while(hashArray[hashVal] != NULL)
   {
      if(hashArray[hashVal]->iData == key)
         return hashArray[hashVal];
      hashVal = (hashVal + 1) % arraySize;
   }
   // We reached a hole, so the key is not in the table.
   return NULL;
}

DataItem* HashTable::remove(int key)
{
   int hashVal = hashFunc(key);
   while(hashArray[hashVal] != NULL)
   {
      if(hashArray[hashVal]->iData == key)
      {
         // Keep the item to hand back, and leave the marker
         // behind so later searches walk on past this cell.
         DataItem* pTemp = hashArray[hashVal];
         hashArray[hashVal] = pNonItem;
         return pTemp;
      }
      hashVal = (hashVal + 1) % arraySize;
   }
   return NULL;
}

find() moves forward from the hash index until it reaches a NULL cell, returning the item as soon as a key matches. A deleted cell holds pNonItem, which is not NULL, so the search steps past it, and since −1 never equals a real key, the marker is never mistaken for a match. remove() runs the same loop. On a match it puts pNonItem into the cell and returns the removed item, so the caller can use it or delete it.

displayTable() prints every cell on one line, showing the key, ** for an empty cell or −1 for a deleted one. main() asks for the table size and the number of starting items and inserts that many random keys. Each key is rand() % (keysPerCell * size) with keysPerCell set to 10, so about ten possible key values map to each cell. Then it loops on a menu, where s shows the table, i inserts, d deletes and f finds. Here is a short session with 13 cells and 8 starting items.

Table size: 13
Items to start with: 8
Command (s, i, d, f): s
Table: ** ** 41 94 108 ** 110 7 59 124 ** ** 38
Command (s, i, d, f): i
Key to insert: 33
Command (s, i, d, f): s
Table: ** ** 41 94 108 ** 110 7 59 124 33 ** 38
Command (s, i, d, f): d
Key to delete: 59
Command (s, i, d, f): s
Table: ** ** 41 94 108 ** 110 7 -1 124 33 ** 38
Command (s, i, d, f): f
Key to find: 124
Found 124

The random keys 7, 59 and 124 all hash to cell 7 (59 is 4 × 13 + 7, and 124 is 9 × 13 + 7), so they ended up side by side in cells 7, 8 and 9. The new key 33 also hashes to 7, so it probes cells 7, 8 and 9 and settles in cell 10. After 59 is deleted, cell 8 shows −1, and the search for 124 steps over it and succeeds.

Wraparound

The statement hashVal = (hashVal + 1) % arraySize; advances and wraps in one step. From any cell but the last, adding 1 gives a number below arraySize, which % leaves alone. From the last cell, arraySize − 1, adding 1 gives exactly arraySize, which % turns into 0. The book writes the same thing as two statements, an increment followed by hashVal %= arraySize;.

370lands1231745646789101111home2412123
Wraparound in a 13-cell table. The keys 11, 24 and 37 all hash to cell 11. The third one probes cells 11 and 12, runs off the end, and lands in cell 0.

Expanding the Array

When a table gets too full, you can move its items into a bigger array. Copying each cell to the same index in the new array won't work, though. The hash function depends on the array size, so a key's proper cell changes with the size. In a 23-cell table the key 52 lives in cell 6 (52 % 23), while in a 47-cell table it belongs in cell 5 (52 % 47).

Instead you rehash, which means going through the old array and inserting every live item into the new one with insert(). Empty cells and markers are skipped, so rehashing also clears out old deletion markers. The new size is typically about double the old one, adjusted to a prime number, so 23 cells would become 47, the first prime after 46. Hour 22 explains why primes matter.

Rehashing costs O(N) time, since every item is inserted again, so it pays to choose a big enough array at the start. A vector's own ability to grow does not help, because growing leaves every item at its old index, which is now the wrong one.

rehash sketch Growing the table (our illustration, not part of hash.cpp)
bool isPrime(int n)
{
   if(n < 2)
      return false;
   for(int d = 2; d * d <= n; d++)
      if(n % d == 0)
         return false;
   return true;
}

void HashTable::rehash()
{
   // Hold on to the old cells, then start over with a bigger array.
   vector<DataItem*> oldArray = hashArray;
   int oldSize = arraySize;
   // Roughly double the size, then move up to the next prime.
   arraySize = 2 * oldSize;
   while(!isPrime(arraySize))
      arraySize++;
   // Grow the vector and blank out every cell.
   hashArray.resize(arraySize);
   for(int j = 0; j < arraySize; j++)
      hashArray[j] = NULL;
   // Re-insert every live item. Holes and markers are left behind.
   for(int j = 0; j < oldSize; j++)
      if(oldArray[j] != NULL && oldArray[j] != pNonItem)
         insert(oldArray[j]);
}

Applied to the 13-cell table from the session above, the sketch builds a 29-cell array (26, 27 and 28 are not prime). Every key moves to key % 29, so 124 lands in cell 9 and 33 in cell 4, and the −1 marker disappears.

Summary

  • A hash table stores each item at an array index computed from its key by a hash function, so insertion and searching take close to O(1) time.
  • Hash tables are hard to expand, can slow down badly when too full, and cannot visit their items in key order.
  • A word can become a number by treating its letters as base-27 digits, and % arraySize squeezes that huge number into the array's range.
  • A collision happens when a key hashes to an occupied cell. Open addressing finds another cell in the array; separate chaining keeps a linked list at every cell.
  • Linear probing checks the following cells one by one, wrapping from the last cell to cell 0. Find follows the same path and gives up at an empty cell.
  • Deleted items are replaced by a marker (key −1 in the C++ code) that insertion may reuse and searches step over.
  • Duplicate keys slow searching and deleting, so they are usually forbidden.
  • Linear probing suffers from primary clustering, so keep its load factor below about one-half to two-thirds.
  • To enlarge a hash table, create a bigger array (about double, adjusted to a prime) and rehash every item into it.

Q&A

If hash tables are so fast, why bother with trees?
Because of what a hash table cannot do. It cannot list its items in key order without sorting them, it needs a good size estimate in advance, and an overfull open-addressing table slows to a crawl. A balanced tree keeps its items in order and grows smoothly, at the price of O(log N) access. Hour 24 weighs these choices.
Why must a linear-probing table always keep some empty cells?
Empty cells are what stop searches. The fuller the table, the longer the clusters and the probe sequences. With the code in this hour, a table with no empty cells at all makes insert(), or a search for a missing key, loop forever.
Can I avoid rehashing altogether?
Only if you know how much data is coming. Rehashing costs time in proportion to the number of items, so choose a generous array at the start and let rehashing be a rare event.

Quiz

  1. What does a hash function do?

    Answer

    It converts a key, or a number from a large range, into a number in a smaller range, usually an index into the hash table's array. The usual tool is key % arraySize.

  2. Using a = 1, b = 2 … z = 26, what number does cats get if you simply add its letter codes?

    Answer

    3 + 1 + 20 + 19 = 43.

  3. Why can't a word's base-27 number (letters multiplied by powers of 27) be used directly as an array index?

    Answer

    The range is far too large. Ten-letter words produce numbers up to 27¹⁰ − 1, over 205 trillion, so the array could not fit in memory, and nearly all its cells would belong to strings that are not words.

  4. What is primary clustering?

    Answer

    In linear probing, occupied cells bunch into runs. A key that hashes anywhere into a run must step to its end and lands just past it, making the run longer, so big clusters grow faster than small ones and probes get longer.

  5. Why is a deleted item replaced by a special marker instead of leaving its cell empty?

    Answer

    Searches stop at the first empty cell. An emptied cell inside a cluster would stop later searches early, making keys beyond it seem missing. The marker tells searches to keep going, while insertion may still reuse the cell.

  6. With linear probing, what is the highest load factor you should normally allow?

    Answer

    About one-half to two-thirds (0.5 to 0.67). Beyond that, probe lengths climb steeply.

22

Part V · Hash Tables

Hour 22 Quadratic Probing

Linear probing's clusters make a crowded table slow. This hour tries two smarter ways of choosing the next cell to probe, quadratic probing and double hashing, and then measures how each open-addressing method copes as the table fills.

In this hour you'll learn

  • How quadratic probing spreads its probes out, and the secondary clustering it still suffers from
  • How double hashing gives each key a step size of its own
  • The two rules every secondary hash function must obey
  • Why the table size should be a prime number
  • How the average number of probes depends on the load factor for each method

In Hour 21 you watched linear probing build clusters that grow faster the bigger they get. Both methods in this hour are still open addressing. Every item lives in the array itself, and a collision sends you looking for another cell. What changes is the order in which the cells are tried.

Quadratic Probing

Clusters form in linear probing because each probe checks the cell right next door. Once the table is fairly full, a key that hashes into a cluster must crawl through all of it and then makes it longer. Quadratic probing tries to stop clusters from forming by probing cells that are farther apart.

The Step Is the Square of the Step Number

With linear probing, if the hash index is x, the probes go to x + 1, x + 2, x + 3 and so on, one cell further each time. With quadratic probing the distance from x is the square of the step number, so the probes go to x + 1, x + 4, x + 9, x + 16, x + 25, which is x + 1², x + 2², x + 3² and onward. Each index is taken % arraySize, so the sequence wraps past the end of the array as before. In code, probe number i examines cell (x + i * i) % arraySize.

The first probes stay close to home, and then the jumps quickly get long. A key that hashes into a cluster escapes it after a few probes and settles somewhere far away, where it does not make the cluster any bigger.

012263home73455526997318910113512131415151617187219lands4320212212345
Quadratic probing in a 23-cell table. The key 72 hashes to cell 3 (72 % 23), inside the cluster in cells 3 to 8. It tries 3 + 1, 3 + 4 and 3 + 9, all taken, then 3 + 16 = 19, which is free.

With linear probing the same key would have walked through cells 3 to 8 and settled in cell 9, seven probes that also lengthen the cluster. Quadratic probing needs five and leaves the cluster alone. Had more probes been needed, the next ones would have been 3 + 25 = 28, which wraps to cell 5, and 3 + 36 = 39, which wraps to cell 16.

The HashDouble Applet with Quadratic Probes

Lafore's HashDouble applet looks like the Hash applet, but when you create a new table you can choose between quadratic probing and double hashing. With quadratic probes selected, you can watch each probe sequence start near the hash index and then leap across the array in ever longer jumps.

As the table nears capacity, the leaps multiply and the sequences get long. Sometimes the applet even reports that it cannot find an empty cell while empty cells plainly exist, because the probe sequence never visits them. The note after the exercise below, and the section on prime table sizes, explain how that can happen. Our re-creation puts both methods in one menu, so you can run the same keys through each and compare.

The Problem with Quadratic Probes

Quadratic probing cures primary clustering, but it has a milder problem of its own. The probe sequence depends only on the hash index, so every key that hashes to the same cell follows exactly the same path. Each newcomer must walk past all the cells its predecessors filled along that path.

Lafore's example uses a 59-cell table and the keys 184, 302, 420 and 538. Each is 7 more than a multiple of 59 (3 × 59 + 7, 5 × 59 + 7, 7 × 59 + 7 and 9 × 59 + 7), so all four hash to cell 7.

Four keys that hash to cell 7 in a 59-cell table, inserted with quadratic probing
KeyHash indexProbe sequenceProbes
184771
30277, 82
42077, 8, 113
53877, 8, 11, 164
01234561847home30289104201112131415538161718192021222324252627282930313233343536373839404142434445464748495051525354555657581234
Secondary clustering. The fourth key, 538, retraces the path of the three before it (cells 7, 8 and 11) before it reaches the free cell 16.

Every extra key with the same hash index needs one more probe than the key before it. This is called secondary clustering. It is gentler than primary clustering, because keys with different hash indexes follow different paths, but it still wastes time. In practice quadratic probing is not used much, because double hashing does better.

Double Hashing

Secondary clustering happens because the probe sequence depends only on the hash index. The cure is to make the sequence depend on the key itself. Double hashing does this by hashing the key a second time, with a different hash function, and using the result as the step size. The step stays fixed throughout one key's probe sequence, but different keys, even keys that share a hash index, generally get different steps.

The secondary hash function must obey two rules.

  • It must differ from the primary hash function. If it were the same, keys that share a hash index would share a step as well, and secondary clustering would be back.
  • It must never return 0. A step of 0 would probe the same cell forever.

Experts have found that a function of the form stepSize = constant - (key % constant); works well, where constant is a prime number smaller than the array size. The book uses 5, giving stepSize = 5 - key % 5;. Since key % 5 lies between 0 and 4, the step lies between 1 and 5 and can never be 0.

Run the four secondary-clustering keys through this formula and they part company. In the 59-cell table, 184 gets a step of 1 (because 184 % 5 is 4), 302 a step of 3, 420 a step of 5 and 538 a step of 2. The first still lands in cell 7, and each of the others finds a free cell on its second probe (cells 10, 12 and 9). The four insertions take 7 probes in all, instead of 10.

Implementation

The program hashDouble.cpp keeps the DataItem class, the vector of pointers and the −1 marker from hash.cpp. The single hash function becomes two member functions.

hashDouble.cpp The two hash functions (members of HashTable)
// Primary hash: chooses the first cell to probe.
int hashFunc1(int key)
{
   return key % arraySize;
}

// Secondary hash: chooses the step size, from 1 to 5.
// It is never 0, and it is a different function from hashFunc1.
// Every cell can be reached only if arraySize shares no factor
// with the possible steps 2, 3, 4 and 5; any prime above 5 will do.
int hashFunc2(int key)
{
   return 5 - key % 5;
}

In this version insert() takes the key as a separate argument alongside the item. Here are insert() and find(), again as members of HashTable.

hashDouble.cpp Probing with a key-dependent step
// Assumes the table is not full.
void insert(int key, DataItem* pItem)
{
   int hashVal = hashFunc1(key);
   int stepSize = hashFunc2(key);
   // Jump stepSize cells at a time until a free cell turns up.
   while(hashArray[hashVal] != NULL &&
         hashArray[hashVal]->iData != -1)
   {
      hashVal = (hashVal + stepSize) % arraySize;
   }
   hashArray[hashVal] = pItem;
}

DataItem* find(int key)
{
   int hashVal = hashFunc1(key);
   int stepSize = hashFunc2(key);
   // Retrace the jumps insert() made, until an empty cell.
   while(hashArray[hashVal] != NULL)
   {
      if(hashArray[hashVal]->iData == key)
         return hashArray[hashVal];
      hashVal = (hashVal + stepSize) % arraySize;
   }
   return NULL;
}

The only change from linear probing is the size of each jump. Where hash.cpp added 1, these functions add stepSize, which hashFunc2() computes once per call. remove() changes in the same way. It starts at hashFunc1(key), advances by hashFunc2(key), and drops the pNonItem marker into the cell when it finds the key. Every operation on a key must use the same step, or find() would search along a different path from the one insert() took.

The book's main() for this program works with a 23-cell table, and Lafore traces what happens as 21 items go into it, one after another. The table below repeats that trace. For each item it gives the key, the hash value (key % 23), the step size (5 − key % 5) and the cells visited, the last of which is where the item ends up.

Filling a 23-cell table with double hashing
ItemKeyHash valueStepCells probed
11141
23815215
33714314
41616416
52020520
63323
71111411
824111, 2
95555
101616416, 20, 1, 5, 9
111010510
1231848
131818218
141212312
1530757
161141, 5, 9, 13
171919119
183613413, 17
194118418, 22
201515515, 20, 2, 7, 12, 17, 22, 4
2125252, 7, 12, 17, 22, 4, 9, 14, 19, 1, 6

Most early items find an empty cell on the first probe. The first collision comes with item 8, where the key 24 hashes to cell 1, already holding 1, and its step of 1 takes it to cell 2. Items 10 and 16 repeat keys that are already present (16 and 1). The program does not forbid duplicates, so each copy simply probes onward to a cell of its own.

The last few rows show what a nearly full table does to probe lengths. Item 20 needs 8 probes and item 21 needs 11, and by then the load factor is 21/23, about 0.91. A search for a key that is absent fares even worse at that point. Looking for 48 (hash value 2, step 2) visits 22 cells before it meets the empty cell 21.

011242start3315455256lands30731816910101111121211337143815161636171818191920202141221234567891011
The table after all 21 insertions. The last key, 25, starts at cell 2 and jumps 5 cells at a time, wrapping around the array twice, until cell 6 on its 11th probe.

Table Size a Prime Number

Double hashing needs one more thing, a table whose size is a prime number. To see why, suppose the table has 15 cells (indices 0 to 14) and a key hashes to cell 0 with a step of 5, as 45 does (45 % 15 is 0, and 5 − 45 % 5 is 5). Its probes go 0, 5, 10, 0, 5, 10 and so on, round and round. Only three cells are ever examined. If those three are full, the insertion never finishes, even though the other 12 cells are empty.

300cycle1234556789101011121314123
A 15-cell table. The key 45 starts at cell 0 with a step of 5, so it only ever visits cells 0, 5 and 10. All three are full, and the probe cycles forever past 12 empty cells.

If the size is prime, no step smaller than the size can divide it evenly, so a probe sequence visits every cell before it repeats. In a 13-cell table, a key such as 65 also hashes to cell 0 with a step of 5, but its sequence runs 0, 5, 10, 2, 7, 12, 4, 9, 1, 6, 11, 3, 8, covering all 13 cells. It finds a free cell as long as one exists.

260home1652lands34556789101011121234
A 13-cell table with the same three cells full. The key 65 (hash value 0, step 5) wraps from cell 10 to cell 2 and lands there on its fourth probe.

The trap exists for any size that shares a factor with one of the possible steps, which is what the comment in hashFunc2() warns about. A prime larger than 5 rules it out. Quadratic probing runs a similar risk, since with a non-prime size its sequences can stay inside a handful of cells.

Efficiency of Open Addressing

With no collisions, inserting or finding an item takes a single probe, so both are O(1). With collisions, the time depends on how many probes are needed on average, and that depends on the load factor, L. The formulas below come from Donald Knuth's analysis. An insertion has to find an empty cell, so it costs about as much as an unsuccessful search.

Linear Probing

For linear probing, the average number of probes P is:

  • successful search: P = (1 + 1 / (1 − L)) / 2
  • unsuccessful search or insertion: P = (1 + 1 / (1 − L)²) / 2

At L = 1/2, a successful search averages 1.5 probes and an unsuccessful one 2.5. At L = 2/3 the figures are 2.0 and 5.0. Here 1 − L is 1/3, whose reciprocal is 3, so the formulas give (1 + 3) / 2 = 2 and (1 + 9) / 2 = 5. At L = 0.8 they reach 3.0 and 13.0, and at L = 0.9 they reach 5.5 and 50.5.

Misses and insertions suffer most, because the squared term explodes as L approaches 1. So keep a linear-probing table below two-thirds full, and preferably below half. The price is memory, since a lower load factor leaves more cells empty, so the choice is a trade between space and speed.

Quadratic Probing and Double Hashing

Quadratic probing and double hashing share a pair of formulas, where ln is the natural logarithm:

  • successful search: P = −ln(1 − L) / L
  • unsuccessful search or insertion: P = 1 / (1 − L)

At L = 0.5 these give 1.4 and 2.0 probes (−ln 0.5 is 0.693, and 0.693 / 0.5 is about 1.39). At L = 0.8 they give 2.0 and 5.0, still comfortable, while linear probing needs 13 probes for a miss at the same load. At L = 0.9 a miss costs 10 probes against linear probing's 50.5. These methods can therefore run with fuller tables, up to a load factor of about 0.8.

Average probes for open addressing at several load factors
Load factor LLinear, foundLinear, not foundQuadratic or double, foundQuadratic or double, not found
0.11.11.11.11.1
0.51.52.51.42.0
2/3 (about 0.67)2.05.01.63.0
0.83.013.02.05.0
0.95.550.52.610.0
048121600.20.40.60.81Load factor LAverage probesLinear probing, successfulLinear probing, unsuccessfulDouble hashing, successfulDouble hashing, unsuccessful
Average probes against load factor. Solid lines are successful searches and dashed lines unsuccessful ones. Linear probing's unsuccessful curve leaves the top of the chart just past L = 0.8, while double hashing's stays low much longer.

Open addressing can never go beyond L = 1, when the array is completely full, and close to that point every method falls apart. At L = 0.99 an unsuccessful search averages about 100 probes with double hashing and about 5,000 with linear probing. Hour 23 introduces separate chaining, a method that degrades far more gently.

Summary

  • Quadratic probing tries the cells x + 1, x + 4, x + 9, x + 16 … from the hash index x, which breaks up primary clustering.
  • Every key with the same hash index follows the same quadratic probe sequence, so such keys pile up along it. This is secondary clustering.
  • Double hashing uses a second hash function to choose each key's step size, so keys that share a hash index usually follow different paths.
  • The secondary hash function must differ from the primary one and must never return 0. The form stepSize = constant - key % constant, with a prime constant smaller than the array size, works well.
  • Double hashing needs a prime table size. Otherwise some probe sequences cycle through only a few cells and may never find an empty one.
  • For linear probing, a successful search averages (1 + 1/(1 − L)) / 2 probes and an unsuccessful one (1 + 1/(1 − L)²) / 2. Keep L below 2/3, ideally below 1/2.
  • For quadratic probing and double hashing, the averages are −ln(1 − L) / L and 1 / (1 − L). A load factor up to about 0.8 is acceptable.
  • Open addressing cannot exceed a load factor of 1, and it performs terribly as it approaches that limit.

Q&A

If quadratic probing beats linear probing, why is it seldom used?
Because double hashing beats it too, with hardly any extra code. Double hashing removes secondary clustering as well as primary clustering.
Why not use one fixed step, say 7, for every key?
A fixed step is just linear probing with longer strides. Keys that collide still follow identical paths, and runs of occupied cells still build up along the stride. Double hashing works because the step depends on the key.
Does the constant in the step formula have to be 5?
No. Any prime smaller than the array size will do; 5 simply gives steps from 1 to 5. The table size must still be prime, so that every possible step can reach every cell.
How does deletion work with double hashing?
Just as with linear probing. The deleted item is replaced by the −1 marker, so searches keep following their probe sequences past it, and insertions may reuse the cell.

Quiz

  1. With quadratic probing, which cells are probed after a key hashes to index x?

    Answer

    x + 1, x + 4, x + 9, x + 16, x + 25 and so on (x plus the square of the step number), each taken % arraySize.

  2. What is secondary clustering?

    Answer

    With quadratic probing, all keys that hash to the same cell follow the same probe sequence, so each new key with that hash index must pass every cell the earlier ones filled and needs one probe more than the key before it.

  3. What is the usual form of the step-size function in double hashing?

    Answer

    stepSize = constant - (key % constant); where the constant is a prime smaller than the array size, for example 5 - key % 5, which gives steps from 1 to 5.

  4. What two rules must a secondary hash function follow?

    Answer

    It must not be the same as the primary hash function, and it must never output 0.

  5. Why should the table size be a prime number when you use double hashing?

    Answer

    If the size shares a factor with a step, that key's probe sequence cycles through only some of the cells (0, 5, 10, 0, 5, 10 … in a 15-cell table with a step of 5), so an insertion can loop forever even though empty cells exist. With a prime size, every step eventually visits every cell.

  6. With linear probing at a load factor of 2/3, how many probes does an unsuccessful search take on average?

    Answer

    (1 + 1/(1/3)²) / 2 = (1 + 9) / 2 = 5 probes.

23

Part V · Hash Tables

Hour 23 Separate Chaining

Separate chaining handles collisions by giving every cell of the array its own linked list, so items that collide simply share a list. This hour shows how chaining works and performs, when to choose it over open addressing, and what makes a good hash function.

In this hour you'll learn

  • How separate chaining stores colliding items in linked lists
  • How the HashChain applet and the hashChain.cpp program work
  • How chaining's speed depends on the load factor
  • When to choose separate chaining and when to choose open addressing
  • What makes a good hash function, including Horner's method for strings and folding for long numbers

Open addressing, the subject of the last two hours, answers a collision by hunting for another cell in the array. Separate chaining installs a linked list at every index of the hash table instead. A key is hashed to an index as usual, and the item is inserted into the list at that index. Other items that hash to the same index simply join the same list, so there is never any need to search the array for an empty cell.

014211—2931031524—5—613
Separate chaining in a 7-cell table. Each cell holds a list of the items that hash to it, such as 14 and 21 in cell 0 (both multiples of 7) and 10, 31 and 52 in cell 3 (each leaves a remainder of 3).

Collisions no longer push items into other cells. They just make one list a little longer.

The HashChain Workshop Applet

Lafore's HashChain applet draws the array vertically, one index per row, with each index's list stretching to the right as a chain of small rectangles. Its buttons are New, Fill, Ins, Find and Del, as in the Hash applet. Ins hashes the key and adds the item to the list at that index. Find hashes the key to reach the right list and then searches along that list only.

Load Factors

The load factor behaves differently with chaining. Each cell can hold any number of items, so it is normal to put N or more items into an N-cell table, and the load factor can be 1 or more. In Lafore's rough picture of a table at L = 1, about a third of the cells are empty, a third hold one item, and a third hold two or more.

Searching means hashing to the right cell and then walking its list. A successful search looks at about half the list on average, L/2 links, after the one step of hashing. So the lists should stay short. A load factor around 1 is typical, and much beyond 2 the lists start to slow things down.

Duplicates

Duplicates are allowed and cause no trouble. Every copy of a key hashes to the same cell, so all copies end up in the same list. Find returns the first copy it meets. To find or delete every copy, you search the whole of that one list, which is short anyway.

Deletion

Deletion is straightforward. Hash to the right cell and unlink the item from its list, as in any linked list (see Hour 8). There are no probe sequences to protect, so no deleted markers are needed, and deletions never slow down later searches.

Table Size

With chaining, a prime table size is not required the way it is for quadratic probing and double hashing, since there are no probe sequences that could cycle. A prime size is still a good idea. If many keys share a factor with the table size, they crowd into a few lists, as the section on hash functions shows.

Buckets

Instead of a linked list, each cell could hold a small array, called a bucket. Buckets share the usual weakness of arrays, a fixed capacity. Make them too big and most of the space is wasted; make them too small and some of them overflow. Linked lists grow exactly as needed, so they are the usual choice.

C++ Code for Separate Chaining

The program hashChain.cpp puts a sorted list from Hour 10 in every cell. Keeping each list sorted speeds up unsuccessful searches, because a search can stop as soon as it passes the place where the key would be. It does not speed up successful searches, which still go about halfway on average, and it makes insertion a little slower, since each new link must go in its proper place instead of at the front.

hashChain.cpp The link and sorted-list classes
#include <iostream>
#include <vector>
#include <cstdlib>
#include <ctime>
using namespace std;

class Link
{
public:
   // The key (a real link would carry more data).
   int iData;
   // The next link in this list, or NULL at the end.
   Link* pNext;
   Link(int it) : iData(it), pNext(NULL)
   { }
   int getKey()
   { return iData; }
   void displayLink()
   { cout << iData << " "; }
};

class SortedList
{
private:
   // First link of the list; NULL while the list is empty.
   Link* pFirst;
public:
   SortedList() : pFirst(NULL)
   { }
   void insert(Link* pTheLink);
   void remove(int key);
   Link* find(int key);
   void displayList();
};

A Link holds one int key, iData, and a pointer to the next link, pNext, which the constructor sets to NULL. A SortedList keeps only pFirst, the pointer to its first link. The two member functions that matter most here are insert() and find().

hashChain.cpp Inserting into and searching a sorted list
// Insert a link so that the keys stay in ascending order.
void SortedList::insert(Link* pTheLink)
{
   int key = pTheLink->getKey();
   Link* pPrevious = NULL;
   Link* pCurrent = pFirst;
   // Walk past every link whose key is smaller than the new one.
   while(pCurrent != NULL && key > pCurrent->getKey())
   {
      pPrevious = pCurrent;
      pCurrent = pCurrent->pNext;
   }
   // Splice the new link in between pPrevious and pCurrent.
   if(pPrevious == NULL)
      pFirst = pTheLink;
   else
      pPrevious->pNext = pTheLink;
   pTheLink->pNext = pCurrent;
}

Link* SortedList::find(int key)
{
   Link* pCurrent = pFirst;
   // The list is sorted, so once the keys grow larger than
   // the one we want, it cannot be further along.
   while(pCurrent != NULL && pCurrent->getKey() <= key)
   {
      if(pCurrent->getKey() == key)
         return pCurrent;
      pCurrent = pCurrent->pNext;
   }
   return NULL;
}

insert() walks two pointers down the list, pPrevious one step behind pCurrent, until pCurrent reaches a key at least as large as the new one or runs off the end. The new link is spliced in between the two. If pPrevious is still NULL, the new link belongs at the front, so pFirst is changed instead.

pFirst103152NULLpPreviouspCurrent45pTheLink
Inserting 45 into the list at cell 3 of the 7-cell table. The walk stops with pPrevious at 31 and pCurrent at 52, and the new link is spliced in between them.

find() walks the list while the keys are no larger than the one it wants, returning the link if it meets the key. As soon as it sees a larger key it gives up, because a sorted list cannot hold the key any further on. A search for 38 in the list 10, 31, 45, 52 stops at 45 after looking at three links. remove() walks the list the same way, with pPrevious trailing pCurrent, and when it finds the key it links pPrevious (or pFirst) past that link and deletes it.

hashChain.cpp A hash table of sorted lists
class HashTable
{
private:
   // One sorted list per cell.
   vector<SortedList*> hashArray;
   int arraySize;
public:
   HashTable(int size) : arraySize(size)
   {
      hashArray.resize(arraySize);
      // Give every cell its own empty list.
      for(int j = 0; j < arraySize; j++)
         hashArray[j] = new SortedList;
   }

   int hashFunc(int key)
   { return key % arraySize; }

   // Each operation hashes the key, then hands the job
   // to the list stored at that index.
   void insert(Link* pTheLink)
   {
      int hashVal = hashFunc(pTheLink->getKey());
      hashArray[hashVal]->insert(pTheLink);
   }

   void remove(int key)
   { hashArray[hashFunc(key)]->remove(key); }

   Link* find(int key)
   { return hashArray[hashFunc(key)]->find(key); }

   void displayTable()
   {
      for(int j = 0; j < arraySize; j++)
      {
         cout << j << ". ";
         hashArray[j]->displayList();
      }
   }
};

The hash table is now a vector of SortedList pointers, and the constructor gives every cell its own empty list. After that, each operation is a single line that hashes the key and passes the work to the list at that index. displayTable() prints each index followed by its list.

As before, main() asks for the table size and the number of items, fills the table with random keys, and loops on a show, insert, delete and find menu. Here is the display for a 13-cell table holding 20 random keys between 0 and 129.

0. List (first-->last): 39 78
1. List (first-->last): 79
2. List (first-->last):
3. List (first-->last):
4. List (first-->last): 121
5. List (first-->last): 122
6. List (first-->last):
7. List (first-->last): 46 59
8. List (first-->last): 47 47 73 73
9. List (first-->last): 22 35 48
10. List (first-->last): 23 88
11. List (first-->last): 11 128
12. List (first-->last): 64 103

The load factor is 20/13, about 1.5. Three lists are empty, three hold a single item, and the longest, at index 8, holds four, including two pairs of duplicates (47 and 73) that chaining stores without any fuss.

Efficiency of Separate Chaining

Finding an item takes one step to hash the key and reach the right list, then a search of that list. With N items spread over arraySize lists, the average list holds N / arraySize items, which is exactly the load factor L. That makes the costs easy to state.

Searching

A successful search examines about half of its list, so it takes about 1 + L/2 probes. An unsuccessful search in an unsorted list must check every link, for 1 + L probes. In a sorted list a miss can stop partway, so it also averages about 1 + L/2.

Insertion

Inserting into an unsorted list takes a single step, since the new link goes straight to the front (unless you first search the list to reject duplicates). With sorted lists, as in hashChain.cpp, insertion must first find the right place, so it costs about 1 + L/2 probes, like a successful search.

Average probes for separate chaining
Load factor LSuccessful search (1 + L/2)Unsuccessful, unsorted lists (1 + L)Unsuccessful, sorted lists (1 + L/2)
0.51.251.51.25
11.521.5
2232
32.542.5
01234500.511.522.53Load factor LAverage probesSuccessful search: 1 + L/2Unsuccessful search: 1 + L
Average probes for separate chaining. Both lines rise in a straight line with the load factor, with no sudden cliff even at L = 2 or 3.

Compare that with open addressing. At L = 0.9, chaining needs about 1.45 probes for a hit and 1.9 for a miss, while linear probing needs 5.5 and 50.5. Chaining degrades gracefully as the load factor grows, and it can go on working at load factors that open addressing cannot reach at all.

Open Addressing Versus Separate Chaining

If you use open addressing, double hashing is generally the better choice over quadratic probing. The exception is when memory is plentiful and the data will not grow once the table is created. Then linear probing is the simplest to program, and at a load factor under 0.5 it costs little.

If you don't know how many items will be inserted, separate chaining is the safer choice. A rising load factor makes open addressing deteriorate sharply, but chaining only gradually. So when in doubt, use separate chaining. It does need a linked-list class, but adding more data than you expected will never bring it to a crawl.

which hash table?size knownplenty of memorysize knownsize unknownor in doubtlinear probingkeep L below 0.5double hashingL up to about 0.8separate chainingL around 1
Choosing a hash table. Separate chaining is the safe default whenever the amount of data is uncertain.

Hash Functions

Every hash table depends on its hash function. A good one is quick to compute and spreads the keys evenly across the cells. How to achieve that depends on what the keys look like.

Quick Computation

The whole point of hashing is speed, so a slow hash function defeats the purpose. A function full of multiplications and divisions can take longer than the table lookup it serves. Bit operations can often stand in for arithmetic. Shifting a number left by one bit, for instance, multiplies it by 2.

Random Keys

A perfect hash function maps every key to a different cell. That is possible only when the keys are unusually well behaved, like the employee numbers of Hour 21, where the index is simply the key itself. In most applications there are far more possible keys than cells, and some collisions are inevitable.

When the keys are random, meaning every value is equally likely, index = key % arraySize; does a fine job. Every cell is equally likely to be chosen, so the keys spread out evenly.

Non-random Keys

Real keys are often far from random. Lafore's example is a car-part number such as 033-400-03-94-05-0-535, in which each group of digits has a meaning.

The fields of a car-part number
DigitsMeaningPossible valuesIn the example
0–2Supplier number1 to 999, currently up to 70033
3–5Category code100, 150, 200 … up to 850400
6–7Month introduced1 to 1203
8–9Year introduced00 to 9994
10–11Serial number1 to 9905
12Toxic risk flag0 or 10
13–15Checksumcomputed from the other fields535

The whole number, 0334000394050535, would be the key for hashing, and its digits are anything but random. The supplier number's first digit is always 0 for now, the category code is always a multiple of 50, and the month never goes above 12. The checksum adds nothing the other fields haven't already said; in the example it is simply their sum, 33 + 400 + 3 + 94 + 5 + 0 = 535. Lafore offers three guidelines for keys like these.

Don't use non-data. Squeeze out every part of the key that carries no new information, so that every bit counts. The checksum can be dropped, because it is computed from the other fields. Category codes that are always multiples of 50 can be divided by 50, which turns 100 through 850 into the small numbers 2 through 17.

Use all the data. Every part of the key that is real data should contribute to the hash value. If you hashed only the first few digits, for example, every part from the same supplier would collide.

Use a prime number for the modulo base. If many keys share a divisor with the array size, they bunch into a few cells. Keys that are all multiples of 10, placed in a table of 50 cells, can only land in cells 0, 10, 20, 30 and 40, leaving 45 cells unused. A prime array size shares no divisor with patterns like these, so it breaks them up. The two small tables below show the same effect.

0102030401—2—3—4—551525356—7—8—9—
Eight multiples of 5 in a 10-cell table crowd into just two lists, at cells 0 and 5.
0—1—235325415556—7408309201010
The same keys in an 11-cell table spread out, one item per list.

Hashing Strings

Hour 21 turned a word into a number by treating its letters as the digits of a base-27 number, so cats became 3 × 27³ + 1 × 27² + 20 × 27¹ + 19 × 27⁰ = 60,337, which was then reduced with % arraySize. Computed that way, the method has two problems. Raising 27 to each power takes many multiplications, and longer words overflow an int. Already 27⁷ is more than 10 billion, far beyond the 2,147,483,647 that a 32-bit int can hold.

Horner's method gets rid of the powers by factoring out 27 over and over, so that 3 × 27³ + 1 × 27² + 20 × 27 + 19 becomes ((3 × 27 + 1) × 27 + 20) × 27 + 19. Starting from the leftmost letter, you multiply the running total by 27 and add the next letter's code. For cats that gives 3, then 3 × 27 + 1 = 82, then 82 × 27 + 20 = 2,234, then 2,234 × 27 + 19 = 60,337, the same answer without a single power.

The running total still grows until it overflows. The fix is to apply % at every step instead of only at the end. The final remainder comes out the same, because (A × 27 + B) % n always equals ((A % n) × 27 + B) % n. The running total then never gets much bigger than 27 times the array size.

hashFunc3 Hashing a string with Horner's method (a HashTable member)
// Hash a lowercase word. Needs #include <string>.
int hashFunc3(string key)
{
   int hashVal = 0;
   // Work through the letters from left to right.
   for(int j = 0; j < int(key.length()); j++)
   {
      // 'a' becomes 1, 'b' becomes 2, and so on.
      int letter = key[j] - 96;
      // Multiply by 27, add the letter, and trim with % at once.
      hashVal = (hashVal * 27 + letter) % arraySize;
   }
   // Already between 0 and arraySize - 1.
   return hashVal;
}
hashFunc3("cats") with an array size of 13
LetterCodeCalculationhashVal
c3(0 × 27 + 3) % 13 = 3 % 133
a1(3 × 27 + 1) % 13 = 82 % 134
t20(4 × 27 + 20) % 13 = 128 % 1311
s19(11 × 27 + 19) % 13 = 316 % 134

The result, 4, matches 60,337 % 13, just as the identity promises. A common variation multiplies by 32 instead of 27. Every letter code is still smaller than the base, and multiplying by 32 is a shift left by 5 bits, which is quicker than a true multiplication.

Folding

Folding is a handy way to hash long numeric keys. Break the key into groups of digits and add the groups together. For a nine-digit Social Security number such as 123-45-6789 and an array of 1,000 cells, use groups of three digits. That gives 123 + 456 + 789 = 1,368, and 1,368 % 1,000 = 368. For an array of 100 cells, use two-digit groups instead, so 12 + 34 + 56 + 78 + 9 = 189, and 189 % 100 = 89.

splitadd% 1,000key123-45-67893-digit groups123 · 456 · 789sum1,368index368
Folding a Social Security number for a 1,000-cell table. The three-digit groups are added, and the sum is reduced modulo the array size.

The group size matches the number of digits in the array size, so the sum already lands near the right range and the modulo only trims it. If the array size is a prime such as 997 or 101, the modulo still does its job, giving 1,368 % 997 = 371 and 189 % 101 = 88.

Summary

  • In separate chaining, each cell of the hash table holds a linked list. Items that collide share a list, and no probing is needed.
  • The load factor can be 1 or more. About 1 is typical, and well above 2 the lists get slow.
  • Duplicates are easy, since every copy lands in the same list, and deletion simply unlinks an item with no marker left behind.
  • A prime table size is optional with chaining, though it still helps spread the keys. Small arrays called buckets could replace the lists, but lists grow as needed.
  • hashChain.cpp keeps each list sorted, so misses can stop early while insertions cost a little more.
  • A successful search takes about 1 + L/2 probes and an unsuccessful one 1 + L (about 1 + L/2 with sorted lists). Performance declines in a straight line as L grows.
  • Among open-addressing methods, double hashing is generally best, and linear probing suits only a fixed amount of data with plenty of memory. When in doubt, use separate chaining.
  • A good hash function is quick and spreads keys evenly. For non-random keys, drop non-data, use all the data, and use a prime modulo base.
  • Horner's method hashes strings without computing powers, and taking % at every step prevents overflow. Folding hashes long numbers by adding groups of digits.

Q&A

Why not always use separate chaining?
It needs a linked-list class, and every link carries a pointer as well as its data. If you know how much data is coming and can spare the memory, a double-hashing table is just as fast and simpler in structure.
Should the lists be sorted?
It depends on the mix of operations. Sorting helps unsuccessful searches and costs a little on insertion. With lists as short as they are at a load factor near 1, the difference is small either way.
Can the load factor go as high as 5 or 10?
The table still works, but every search then walks a list of 5 or 10 items, a small linear search. If the data grows that much, rehash into a bigger array.
Why multiply by 27 when hashing strings?
The code has 27 symbols, the blank plus 26 letters, so base 27 keeps different strings from producing the same number before the modulo is applied. Any larger base also works, and 32 is popular because multiplying by it is a quick shift.

Quiz

  1. In separate chaining, what does each cell of the hash table hold, and what load factor is typical?

    Answer

    A linked list (a sorted list in hashChain.cpp) of all the items that hash to that index. A load factor of about 1 is typical.

  2. At a load factor of 2, about how many probes does a successful search take?

    Answer

    1 + L/2 = 1 + 2/2 = 2 probes.

  3. When should you prefer separate chaining to open addressing?

    Answer

    When you don't know how many items will be inserted, or whenever you are in doubt. Its performance declines only gradually as the load factor rises, while open addressing deteriorates sharply.

  4. What three guidelines help when hashing non-random keys?

    Answer

    Don't use non-data (squeeze out parts such as checksums that add no information), use all the data (let every real field contribute), and use a prime number for the modulo base.

  5. What does Horner's method avoid, and how does hashFunc3() also avoid overflow?

    Answer

    It avoids computing powers of 27 by repeatedly multiplying the running total by 27 and adding the next letter. Applying % at every step keeps the running total small, so it never overflows.

  6. Folding the Social Security number 123-45-6789 into a 1,000-cell array gives which index?

    Answer

    123 + 456 + 789 = 1,368, and 1,368 % 1,000 = 368.

24

Part V · Hash Tables

Hour 24 When to Use What

The last hour adds no new structures. It steps back and turns the previous twenty-three hours into a way of choosing: which structure to store data in, which tool to reach for inside a program, and which sort to run.

In this hour you'll learn

  • The questions to ask before picking a data structure
  • When arrays, linked lists, trees and hash tables each make sense
  • How to choose between stacks, queues and priority queues
  • Which sort to use for small, large and nearly sorted data
  • Where to go after this book

By now you have seen a dozen ways to organize data and half a dozen ways to sort it. Each one was fast at some things and slow at others. Nothing is best at everything, so the useful skill is matching a structure to a job.

Before choosing, ask a few plain questions about your data. How many items will there be: dozens, thousands, millions? Do you know that number in advance? Which operations happen most: inserting, searching, deleting, or visiting everything in order? Could the keys arrive already sorted? And how much effort can you afford to spend writing and debugging the code? The answers usually point to one or two candidates.

General-Purpose Data Structures

The general-purpose structures are the ones you use to store real-world records: personnel files, inventories, dictionaries, address books. There are four families: arrays, linked lists, trees and hash tables. Roughly speaking they get faster in that order, and also more complicated.

yesnoyesnoyesnoyesnoyesnoSmall amount of data?(say, a few hundred items)Size knownin advance?Need keys in order?(sorted output, min/max)Searches more commonthan insertions?Linked listCould keys arrivealready sorted?Hash tableOrdered arrayUnordered arrayBalanced tree(red-black, 2-3-4)Binary search tree
A rough decision guide for storing data. Small, predictable data suits arrays; unpredictable sizes suit lists; large data goes to a tree when order matters and to a hash table when it doesn't.

Speed and Algorithms

The table below collects the running times you met across the book. Remember what Big O hides: constants, memory use, and the cost of writing the code. An O(log N) tree beats an O(N) array only once N is big enough to matter, and a hash table that is nearly full can behave far worse than its O(1) promise.

General-purpose structures at a glance
StructureSearchInsertionDeletionVisit in key order
Unordered arrayO(N)O(1)O(N)Sort first
Ordered arrayO(log N)O(N)O(N)O(N)
Linked listO(N)O(1) at the frontO(N)Sort first
Sorted linked listO(N)O(N)O(N)O(N)
Binary search tree, random keysO(log N)O(log N)O(log N)O(N)
Binary search tree, sorted keys (worst case)O(N)O(N)O(N)O(N)
Balanced tree (red-black, 2-3-4)O(log N)O(log N)O(log N)O(N)
Hash tableO(1)O(1)O(1)Not supported

Arrays

Use an array when the amount of data is modest and you can predict how much there will be. Arrays are the simplest structure to write, they waste no memory on pointers, and they give instant access by index.

Choose the flavor by the operation that dominates. If you insert far more often than you search, an unordered array wins: each insertion is a single step at the end. If you search far more often than you insert, an ordered array wins: binary search finds any of a million items in about 20 comparisons (Hour 3). Deletion is slow in both, because the items above the hole must shift down.

The weak spot is size. An array is created with a fixed number of cells. Guess too low and it overflows; guess too high and memory sits idle. A vector can grow, but growing means copying every item into a bigger block, which is slow if it happens often.

Linked Lists

Reach for a linked list when you can't predict how many items you'll have, or when items are inserted and deleted constantly. A list grows one link at a time and never needs copying. Inserting or removing at the front is O(1), and removing a found item only changes pointers; nothing slides around.

The price is searching. There is no binary search in a list, because you can only reach an item by walking the chain from the start. That makes lists a poor fit for large amounts of data that must be searched often. A sorted list helps a little (a search can stop early), and a doubly linked list lets you walk backward and delete from either end (Hour 10).

Binary Search Trees

When arrays and lists prove too slow, a binary search tree is the next step. It finds, inserts and deletes in O(log N) time, and an inorder traversal hands you the keys in sorted order, which a hash table can't do. The minimum and maximum are a short walk down the left or right edge.

There is one catch, and it matters: the tree stays fast only if keys arrive in a fairly random order. Feed it sorted or reverse-sorted keys and it degenerates into a linked list, with O(N) operations (Hour 17). Use a plain binary search tree only when you're confident the input is random.

Balanced Trees

Red-black trees and 2-3-4 trees guarantee O(log N) no matter what order the keys arrive in. They are the safe choice for large amounts of data that must stay in order. Their drawback is complexity: the insertion and especially the deletion code is long and easy to get wrong. If a well-tested library version exists, use it.

Hash Tables

A hash table is the fastest way to store and retrieve items by key: insertion, search and deletion all take close to constant time. It is the natural choice for a spelling checker's dictionary, a compiler's table of variable names, or any lookup where you know the key and don't care about order.

Keep its limits in mind. It can't list items in key order or find the smallest one without examining everything. Performance depends on a good hash function and on the load factor. With open addressing the table must not get too full (keep linear probing under about half, double hashing under about two-thirds), so you need a decent estimate of the size, and growing the table means rehashing every item. Separate chaining is far more forgiving: its performance degrades only gradually as the load factor rises (Hour 23). When in doubt, chain.

Special-Purpose Data Structures

Stacks, queues and priority queues are not usually where a program stores its main data. They are tools the program uses while it works: to remember where it was, to line up jobs, to pick the most urgent task next. Each deliberately restricts access to one item at a time, which is exactly what makes them simple and fast (Hours 6 and 7).

  • Stack (last in, first out): when the most recent item must be handled first. Matching delimiters, reversing sequences, undo histories, backtracking through a maze, and the way function calls themselves are managed.
  • Queue (first in, first out): when items must be served in arrival order. Print jobs, keyboard buffers, messages between parts of a program, and simulations of people or cars waiting in line.
  • Priority queue: when the most important item must come out first regardless of arrival order. Task scheduling by priority, and simulations that always process the earliest next event.

Each of these is an abstract data type (Hour 9): you choose the interface first and the implementation second. Use an array underneath when you know the maximum size, since it's simple and fast; use a linked list when the size is unpredictable.

Special-purpose structures
StructureImplementationInsertionDeletionComment
StackArray or linked listO(1) pushO(1) popRemoves the most recently inserted item
QueueCircular array or double-ended listO(1) at the rearO(1) at the frontRemoves the oldest item
Priority queueOrdered arrayO(N)O(1)Removes the item with the smallest key
Priority queueHeap (beyond this book)O(log N)O(log N)Better when there are many items

Sorting

The same advice applies to sorting: begin simple. For a small number of items, or data that is already nearly in order, the insertion sort is hard to beat. It is short, it sorts in place, it is stable, and on almost-sorted data it runs in close to O(N) time (Hour 5). The bubble sort is even simpler but roughly twice as slow, so it's rarely worth choosing.

When the insertion sort becomes too slow, move to quicksort, usually the fastest general-purpose sort for data in memory. Use the median-of-three pivot so that sorted or reverse-sorted input doesn't push it into its O(N²) worst case, and hand small partitions to an insertion sort (Hour 14).

Mergesort is the choice when you need a guarantee: it is O(N log N) every time, and it is stable. It pays for that with a second array as large as the first. It also shines when the data doesn't fit in memory: merging long sorted runs from disk is the classic way to sort huge files.

small or nearly sortedlargenoyesHow much data?Insertion sortNeed a guaranteed time,stability, or disk files?Quicksort(median-of-three)Mergesort
Choosing a sort: insertion sort for small or nearly sorted data, quicksort for large data in memory, mergesort when you need guaranteed O(N log N), stability, or external sorting.
The sorts in this book
SortAverageWorstExtra memoryStable
Bubble sortO(N²)O(N²)NoYes
Insertion sortO(N²)O(N²)NoYes
MergesortO(N log N)O(N log N)A second array of N itemsYes
QuicksortO(N log N)O(N²), rare with median-of-threeNo (just the recursion)No

Onward

This book gives you a working foundation, not the whole field. Several important topics lie just past its edge, and you're now ready for them:

  • Heaps, a tree-shaped structure stored in an array that makes priority queues O(log N) for both insertion and removal, and gives a sort of its own: heapsort.
  • Graphs, which model networks such as roads, airline routes and web links, along with the algorithms that search them, find shortest paths and build minimum spanning trees.
  • Other sorts, such as Shellsort and radix sort, and a deeper look at sorting files too big for memory.
  • Other balanced trees, such as the AVL tree mentioned in Hour 18, and B-trees for databases on disk.

Lafore's larger book on data structures (written with Java) covers heaps and graphs in the same hands-on style, and Appendix C points to the classic references. Whatever you read next, the habits from these 24 hours carry over: draw the structure, trace the pointers by hand, count the steps, and ask what happens as N grows.

Summary

  • Choosing a structure starts with questions: how much data, whether the size is known, which operations dominate, and whether keys might arrive sorted.
  • Try the simplest structure first and move up only when measurements say it's too slow.
  • Arrays suit small, predictable data; ordered arrays favor searching, unordered arrays favor insertion.
  • Linked lists suit unpredictable sizes and frequent insertion and deletion, but search slowly.
  • Binary search trees give O(log N) operations and sorted traversal, but only with randomly ordered keys; balanced trees guarantee O(log N).
  • Hash tables are the fastest for lookups by key but can't give ordered output; separate chaining handles growth most gracefully.
  • Stacks, queues and priority queues are programmer's tools with restricted access, built on arrays or linked lists.
  • Sort small or nearly sorted data with insertion sort, large data with quicksort, and use mergesort when you need guaranteed time, stability or external sorting.

Q&A

Is Big O all I need to compare two structures?
No. Big O describes how the time grows for large N. For small N, the constants it ignores often dominate, so a "slower" array can beat a "faster" tree. Memory use, how hard the code is to write correctly, and how often each operation really happens all matter too.
If libraries already provide these structures, why learn how they work?
Because choosing well requires knowing the costs inside. A library map and a library hash table look almost identical to use, but one keeps keys in order in O(log N) time and the other finds them in O(1) without order. Knowing why helps you pick the right one, predict its behavior on your data, and build a custom structure when nothing off the shelf fits.
What should I do when the data is too big for memory?
Design around disk reads, which are thousands of times slower than memory access. B-trees keep each node the size of a disk block so that a search touches only a few blocks, hashing can send each key straight to the block that holds it, and mergesort can sort a huge file by merging sorted runs.

Quiz

  1. You'll store about 200 records, the number hardly changes, and lookups by key vastly outnumber insertions. Which structure fits best?

    Answer

    An ordered array searched with binary search. The data is small and predictable, and the rare insertions can afford to shift items.

  2. Give two reasons not to use a hash table for everything, even though it's the fastest.

    Answer

    It can't visit items in key order or find the minimum quickly, and it needs a reasonable size estimate and a good hash function; with open addressing its performance collapses as it fills up.

  3. The keys you must store will arrive already sorted. Why is a plain binary search tree a bad idea, and what should you use?

    Answer

    Sorted input makes every new node a child of the previous one, so the tree becomes a linked list with O(N) operations. Use a balanced tree such as a red-black tree.

  4. Which sort is best for data that is already almost in order?

    Answer

    The insertion sort: each item moves only a short distance, so it runs in nearly O(N) time.

  5. Name one advantage of quicksort over mergesort, and one of mergesort over quicksort.

    Answer

    Quicksort sorts in place and is usually faster in practice. Mergesort guarantees O(N log N), is stable, and works well for data on disk.

  6. You need a stack and know it will never hold more than 100 items. Array or linked list?

    Answer

    An array: it's simpler and faster, with no pointer overhead. A linked list is the better choice only when the maximum size is unknown.

Back matter

Appendixes The Book on One Page, and Appendixes A–C

A one-page recap of all 24 hours for quick review, followed by the book's three appendixes: quiz answers, how to run the Workshop applets and sample programs, and where to read next.

The Book on One Page

Use this table to review before a test or to find the hour that covers something you've forgotten. Each row links to its hour.

All 24 hours at a glance
HourTopicThe big ideaCost to remember
1OverviewData structures arrange data; algorithms work on them. Records have fields; one field is the key. Classes bundle data with the functions that use it.—
2ArraysInsert at the end, search cell by cell, delete by shifting items down. A class interface hides the details.Insert O(1), search and delete O(N)
3Ordered arraysBinary search halves the range each step. Logarithms count the halvings. Big O ignores constants.Search O(log N), insert O(N)
4Bubble sortCompare neighbors, swap if out of order; the largest item bubbles to the end each pass.O(N²), about N²/2 comparisons
5Insertion sortTake the next item and slide it into the sorted part. Stable, and fast on nearly sorted data.O(N²), about N²/4 comparisons; near O(N) if almost sorted
6StacksLast in, first out: push, pop, peek. Reverses words and matches delimiters.O(1) push and pop
7Queues and priority queuesFirst in, first out in a circular array; deques work at both ends; a priority queue always releases the smallest key.Queue O(1); ordered-array priority queue: insert O(N), remove O(1)
8Linked listsEach link points to the next. Insert at the front, find by walking, remove by rerouting a pointer.Insert at front O(1), find O(N)
9Abstract data typesDefine what a structure does separately from how. Stacks and queues on linked lists; double-ended lists.Same interface, different costs
10Specialized listsSorted lists, list insertion sort, and doubly linked lists that can go both ways.Sorted insert O(N), remove smallest O(1)
11RecursionA function that calls itself on a smaller problem, with a base case to stop. Triangular numbers, anagrams, recursive binary search.Extra call overhead; any recursion can use a stack instead
12Applied recursionTowers of Hanoi by moving subtrees; mergesort by sorting halves and merging them.Hanoi 2ⁿ − 1 moves; mergesort O(N log N) with a second array
13QuicksortPartition around a pivot, put the pivot in its final place, sort both sides recursively.O(N log N) on average
14Improving quicksortMedian-of-three pivots stop sorted data from causing O(N²); small partitions go to insertion sort.Worst case O(N²) becomes rare
15Binary treesLeft child smaller, right child larger or equal. Find and insert follow one path; delete has three cases.O(log N) when balanced
16Traversing binary treesInorder gives sorted order; preorder and postorder give prefix and postfix expressions. Minimum is far left.Traversal O(N); levels ≈ log₂(N+1)
17Red-black treesFour color rules force balance. Color flips and rotations repair violations.Guaranteed O(log N)
18Red-black insertionsFlip black nodes with two red children on the way down; fix red-red with one rotation (outside grandchild) or two (inside).About one rotation per insertion
192-3-4 treesNodes hold 1 to 3 items and 2 to 4 children; full nodes split on the way down, so the tree grows at the root and stays level.O(log N)
20Implementing 2-3-4 treesDataItem, Node and Tree234 classes. Every 2-3-4 tree maps to a red-black tree: splits are color flips.O(log N); wastes some memory
21Hash tablesA hash function turns a key into an index (key % size). Collisions are resolved by linear probing; keep the load factor low.O(1) average
22Quadratic probingProbe at +1, +4, +9…; better still, double hashing with a key-dependent step. Table sizes should be prime.Stays fast up to higher load factors
23Separate chainingEach cell holds a linked list. Good hash functions use all of the key and a prime table size.About 1 + L/2 probes to find an item
24When to use whatMatch the structure and the sort to the data size, the dominant operations and the need for order.—

Appendix A: Quiz Answers

In the book, the answers to every end-of-hour quiz are collected here in Appendix A. In this companion each answer sits directly under its question: press Answer to reveal it. Try to answer in your own words first; the quizzes are short on purpose, and the act of recalling is what makes the material stick.

Jump to a quiz: 1 · 2 · 3 · 4 · 5 · 6 · 7 · 8 · 9 · 10 · 11 · 12 · 13 · 14 · 15 · 16 · 17 · 18 · 19 · 20 · 21 · 22 · 23 · 24

Appendix B: How to Run the Workshop Applets and Sample Programs

The Workshop Applets

The book's CD carried the Workshop applets as Java applets: small programs that ran inside a Java-enabled web browser, or in the appletviewer utility that came with Sun's Java Development Kit. Each one lived in its own folder with an HTML page that loaded it. You opened that page, and the applet's buttons, bars and trees appeared.

Web browsers have since dropped support for Java applets entirely, so the originals won't start in a modern browser. That is why every hour of this companion has a Workshop panel that re-creates the matching applet in JavaScript, with the same buttons wherever possible. The table shows where each one lives.

The book's applets and their re-creations here
Applet in the bookHourPanel on this page
Array2Array
Ordered3Ordered array
bubbleSort4Bubble sort
insertSort5Sorting (insertion and bubble side by side)
Stack6Stack
Queue, PriorityQ7Queue; Priority queue
LinkList8, 10Linked list; Sorted linked list
Towers, mergeSort12Towers of Hanoi; Mergesort
Partition, quickSort113Partition; Quicksort 1
quickSort214Quicksort 1 and 2 on reversed data
Tree15, 16Binary search tree
RBTree17, 18Red-black tree (manual and auto modes)
Tree23419, 202-3-4 tree (with a red-black view)
Hash, HashDouble, HashChain21–23Linear probing; Quadratic and double hashing; Separate chaining

A few panels have no counterpart on the CD. They turn examples from the text into something you can play with: the guess-a-number game and the Big O growth table (Hour 3), delimiter matching (Hour 6), one interface with two implementations (Hour 9), and the triangle and anagram tracers (Hour 11).

The Sample Programs

The book's C++ examples are console programs: they print to a text window and some read keystrokes or numbers from it. They use Standard C++ (the string and vector classes and new-style headers such as <iostream>), so any modern compiler builds them. The listings in this companion are written the same way.

  1. Save a program in a file ending in .cpp, for example bubbleSort.cpp.
  2. With GCC or Clang, compile from a terminal: g++ -std=c++17 -Wall bubbleSort.cpp -o bubbleSort, then run ./bubbleSort (on Windows, bubbleSort.exe).
  3. With Visual Studio, create an empty C++ console project, add the file, and press Ctrl+F5 so the console window stays open when the program ends.

Appendix C: Further Reading

The book closes with suggestions in four areas. Here's what each area is for, and books that are widely recommended in it.

Data Structures and Algorithms

The natural next step: more structures (heaps, graphs, B-trees), more sorts, and the mathematics of why algorithms cost what they do.

Object-Oriented Programming Languages

A solid grip on C++ itself makes every listing easier: classes, pointers, memory management, templates, and the Standard Library that now provides most of these structures ready-made.

Object-Oriented Design and Software Engineering

How to go from a problem to a set of classes, and how real programs are planned, built, tested and maintained over years.

Programming Style

How to write code that other people (including you, six months later) can read, trust and change.