Linear Structure¶

Open In Colab No description has been provided for this image

Execute the following two cells for the setup:

In [ ]:
from jupyterquiz import display_quiz

path = "questions/ch5/"
  1. Introduction

  2. Stacks

  3. Queues

  4. Deques

3.1~3.2 What Are Linear ADT?¶

They are data collections whose items are ordered depending on how they are added or removed. Once an item is added, it stays in that position relative to the other elements that came before and came after it. Collections such as these are often referred to as linear ADT.

Linear ADT can be thought of as having two ends. Sometimes these ends are referred to as the left and the right, or in some cases the front and the rear.

What distinguishes one linear structure from another is the way in which items are added and removed, in particular the location where these additions and removals occur.

3.3 Stacks¶

A stack (sometimes called a push-down stack) is an ordered collection of items where the addition of new items and the removal of existing items always takes place at the same end. This end is commonly referred to as the top. The end opposite the top is known as the base.

The base of the stack is significant since items stored in the stack that are closer to the base represent those that have been in the stack the longest. The most recently added item is the one that is in position to be removed first. This ordering principle is sometimes called LIFO, or last in, first out.

It provides an ordering based on length of time in the collection. Newer items are near the top, while older items are near the base.

Almost any cafeteria has a stack of trays or plates where you take the one at the top, uncovering a new tray or plate for the next customer in line. Imagine a stack of books on a desk. The only book whose cover is visible is the one on top. To access others in the stack, we need to remove the ones that are sitting on top of them.

No description has been provided for this image

Assume you start out with a clean desktop. Now place books one at a time on top of each other. You are constructing a stack. Consider what happens when you begin removing books. The order that they are removed is exactly the reverse of the order that they were placed.

Stacks are fundamentally important, as they can be used to reverse the order of items. The order of insertion is the reverse of the order of removal:

No description has been provided for this image

For example, every web browser has a Back button. As you navigate from web page to web page, those pages are placed on a stack (actually it is the URLs that are going on the stack). The current page that you are viewing is on the top and the first page you looked at is at the base. If you click on the Back button, you begin to move in reverse order through the pages!

3.4 The Stack Abstract Data Type¶

The stack operations are given below:

  • Stack() creates a new stack that is empty. It needs no parameters and returns an empty stack.
  • push(item) adds a new item to the top of the stack. It needs the item and returns nothing.

  • pop() removes the top item from the stack. It needs no parameters and returns the item. The stack is modified.

  • peek() returns the top item from the stack but does not remove it. It needs no parameters. The stack is not modified.
  • is_empty() tests to see whether the stack is empty. It needs no parameters and returns a boolean value.
  • size() returns the number of items on the stack. It needs no parameters and returns an integer.

Under Stack Contents, the top item is listed at the far right:

Stack Operation Stack Contents Return Value
s.is_empty() [] True
s.push(4) [4]
s.push('dog') [4, 'dog']
s.peek() [4, 'dog'] 'dog'
s.push(True) [4, 'dog', True]
s.size() [4, 'dog', True] 3
s.is_empty() [4, 'dog', True] False
s.push(8.4) [4, 'dog', True, 8.4]
s.pop() [4, 'dog', True] 8.4
s.pop() [4, 'dog'] True
s.size() [4, 'dog'] 2

3.5 Implementing a Stack in C++¶

Now that we have clearly defined the stack as an ADT, we will turn our attention to using C++ to implement the stack. We build a class whose underlying storage is a vector — the direct counterpart of the Python list implementation. (A fixed-size array version is in pythonds3/cppds/basic/stack.cpp.)

The implementation of choice for an abstract data type such as a stack is the creation of a new class. The stack operations are implemented as methods. Further, to implement a stack, which is a collection of elements, it makes sense to utilize the power and simplicity of the list provided by Python.

Recall that the list class in Python provides an ordered collection mechanism and a set of methods. We need only to decide which end of the list will be considered the top of the stack and which will be the base. Once that decision is made, the operations can be implemented using the list methods such as append() and pop().

Assume that the end (back) of the vector will hold the top element of the stack. Note one difference from Python: our template stack holds elements of a single type chosen when it is created:

template <typename T>
class Stack {
    private:
        vector<T> items;

    public:
        bool isEmpty() {
            return items.empty();
        }
        void push(T item) {
            items.push_back(item);
        }
        int size() {
            return items.size();
        }
};
T pop() {
    T top = items.back();
    items.pop_back();
    return top;
}
T peek() {
    return items.back();
}
void display() {
    for (T x : items) cout << x << " ";
    cout << endl;
}

(complete class: dscpp/stack.hpp)

Notice that the definition of the Stack class is imported from the pythonds3 module that is included with the materials for this book.

A fixed-capacity array version of the same class is collected in pythonds3/cppds/basic/stack.cpp. Here is the vector-backed stack in action:

In [1]:
#include <iostream>
#include "dscpp/stack.hpp"   // the vector-backed Stack of this section
using namespace std;

int main() {
    Stack<double> s;
    cout << boolalpha << s.isEmpty() << endl;
    s.push(4);
    s.push(7.5);
    cout << s.peek() << endl;
    s.push(8.4);
    s.display();
    cout << s.pop() << endl;
    cout << s.size() << endl;
    return 0;
}

true

7.5

4 7.5 8.4

8.4

2

We could instead keep the top at the beginning of the vector:

template <typename T>
class Stack2 {
    private:
        vector<T> items;

    public:
        bool isEmpty() {
            return items.empty();
        }
        void push(T item) {
            items.insert(items.begin(), item);   // O(n)!
        }
        T pop() {
            T top = items.front();
            items.erase(items.begin());   // O(n)!
            return top;
        }
        T peek() {
            return items.front();
        }
        int size() {
            return items.size();
        }
};

In this case, push_back() and pop_back() no longer touch the top, so we use insert(begin()) and erase(begin()) instead — every push and pop now shifts all elements, turning $O(1)$ operations into $O(n)$.

In [2]:
#include <iostream>
#include "dscpp/stack.hpp"   // Stack2: top at the front, O(n) ops
using namespace std;

int main() {
    Stack2<double> s;
    cout << boolalpha << s.isEmpty() << endl;
    s.push(4);
    s.push(7.5);
    cout << s.peek() << endl;
    s.push(2);
    cout << s.size() << endl;
    cout << s.pop() << endl;
    cout << s.pop() << endl;
    cout << s.size() << endl;
    return 0;
}

true

7.5

3

2

7.5

1

This ability to change the physical implementation of an ADT while maintaining the logical characteristics is an example of abstraction at work. However, even though the stack will work either way, if we consider the performance of the two implementations, there is definitely a difference!

Recall that the append() and pop() operations were both $O(1)$. This means that the first implementation will perform push() and pop() in constant time no matter how many items are on the stack.

The performance of the second implementation suffers in that the insert(0) and pop(0) operations will both require $O(n)$ for a stack of size $n$. Clearly, even though the implementations are logically equivalent, they would have very different timings when performing benchmark testing!

In [ ]:
display_quiz(path+"stack1.json", max_width=800)
In [ ]:
display_quiz(path+"stack2.json", max_width=800)

Exercise: Write a function rev_string(my_str) that uses a stack to reverse the characters in a string.¶

In [3]:
#include <iostream>
#include <stack>
using namespace std;

string revString(string myStr) {
    stack<char> s;
    // Step 1: push every char onto s;  Step 2: pop to build rStr
    string rStr = "";
    while (!s.empty()) {
        rStr = 
    }
    return rStr;
}

int main() {
    cout << revString("NSYSU") << endl;
    return 0;
}
/tmp/tmpl39zn56s.cpp: In function ‘std::string revString(std::string)’:
/tmp/tmpl39zn56s.cpp:13:5: error: expected primary-expression before ‘}’ token
   13 |     }
      |     ^
[C++ kernel] Error: Unable to compile the source code. Return error: 0x1.

Expected output: USYSN

3.6. Simple Balanced Parentheses¶

We now turn our attention to using stacks to solve real computer science problems. You have no doubt written arithmetic expressions such as:

$$(5 + 6) * (7 + 8) / (4 + 3)$$

where parentheses are used to indicate the order of operations.

You may also have some experience programming in a language with constructs like

(defun square(n)
     (* n n))

In both of these examples, parentheses must appear in a balanced fashion. Balanced parentheses means that each opening symbol has a corresponding closing symbol and the pairs of parentheses are properly nested.

The ability to differentiate between parentheses that are correctly balanced and those that are unbalanced is an important part of recognizing many programming language structures.

The challenge then is to write an algorithm that will read a string of parentheses from left to right and decide whether the symbols are balanced.

As you process symbols from left to right, the most recent opening parenthesis must match the next closing symbol. Also, the first opening symbol processed may have to wait until the very last symbol for its match.

No description has been provided for this image

Closing symbols match opening symbols in the reverse order of their appearance; they match from the inside out. This is a clue that stacks can be used to solve the problem!

  • Process each parenthesis in the string from left to right, starting with an empty stack.
  • Push opening parentheses onto the stack as a marker for a needed matching closing parenthesis.
  • For each closing parenthesis, pop the stack.
  • The string is balanced if every closing parenthesis matches a popped opening parenthesis and the stack is empty at the end of the string. If there's no matching opening parenthesis for any closing parenthesis or the stack is not empty at the end, the string is unbalanced!
In [4]:
#include <iostream>
#include <stack>
#include <string>
using namespace std;

bool parChecker(string symbolString) {
    stack<char> s;
    for (char symbol : symbolString) {
        if (symbol == '(') {
            s.push(symbol);
        } else {
            if (s.empty()) {
                return false;
            } else {
                s.pop();
            }
        }
    }
    return s.empty();
}

int main() {
    cout << boolalpha;
    cout << parChecker("((()))") << endl;    // expected true
    cout << parChecker("((()()))") << endl;  // expected true
    cout << parChecker("(()") << endl;       // expected false
    cout << parChecker(")(") << endl;        // expected false
    return 0;
}

true

true

false

false

Expected: true true for the balanced strings.

Expected: false false for the unbalanced strings.

This function, par_checker(), assumes that a Stack class is available and returns a Boolean result as to whether the string of parentheses is balanced.

If the current symbol is (, then it is pushed on the stack (lines 6–7). Note also in line 12 that pop() simply removes a symbol from the stack. The returned value is not used since we know it must be an opening symbol seen earlier.

If the stack becomes empty before we reach the end of the symbol_string, then there are too many closing parentheses and the string is not balanced, so we immediately return False (line 11).

At the end (line 15), the string represents a correctly balanced sequence of parentheses as long as the stack has been completely cleaned off.

3.7. Balanced Symbols (A General Case)¶

The general problem of balancing and nesting different kinds of opening and closing symbols occurs frequently.

For example, in Python square brackets, [ and ], are used for lists; curly braces, { and }, are used for sets and dictionaries; and parentheses, ( and ), are used for tuples and arithmetic expressions.

It is possible to mix symbols as long as each maintains its own open and close relationship.

Strings of symbols such as:

{ { ( [ ] [ ] ) } ( ) }

[ [ { { ( ( ) ) } } ] ]

[ ] [ ] [ ] ( ) { }

are properly balanced in that not only does each opening symbol have a corresponding closing symbol, but the types of symbols match as well.

Compare those with the following strings that are not balanced:

( [ ) ]

( ( ( ) ] ) )

[ { ( ) ]

Recall that each opening symbol is simply pushed on the stack to wait for the matching closing symbol to appear later in the sequence. When a closing symbol does appear, the only difference is that we must check to be sure that it correctly matches the type of the opening symbol on top of the stack.

If the two symbols do not match, the string is not balanced. Once again, if the entire string is processed and nothing is left on the stack, the string is correctly balanced.

The Python code only change appears in line 12 where we call a helper function, matches, to assist with symbol-matching. Each symbol that is removed from the stack must be checked to see that it matches the current closing symbol.

In [5]:
#include <iostream>
#include <stack>
#include <string>
using namespace std;

bool matches(char symLeft, char symRight) {
    string allLefts = "([{";
    string allRights = ")]}";
    return allLefts.find(symLeft) == allRights.find(symRight);
}

bool balanceChecker(string symbolString) {
    stack<char> s;
    for (char symbol : symbolString) {
        if (symbol == '(' || symbol == '[' || symbol == '{') {
            s.push(symbol);
        } else {
            if (s.empty()) {
                return false;
            } else {
                char top = s.top();
                s.pop();
                if (!matches(top, symbol)) {
                    return false;
                }
            }
        }
    }
    return s.empty();
}

int main() {
    cout << boolalpha;
    cout << balanceChecker("{({([][])}())}") << endl;  // expected true
    cout << balanceChecker("[{()]") << endl;           // expected false
    return 0;
}

true

false

Expected: true then false.

These two examples show that stacks are very important data structures for the processing of language constructs in computer science. Almost any notation you can think of has some type of nested symbol that must be matched in a balanced order.

There are a number of other important uses for stacks in computer science. We will continue to explore them in the next sections!

3.8. Converting Decimal Numbers to Binary Numbers¶

Binary representation is important in computer science since all values stored within a computer exist as a string of binary digits, a string of 0s and 1s.

Integer values are common data items. They are used in computer programs and computation all the time. We learn about them in math class and of course represent them using the decimal number system, or base 10. The decimal number $233_{10}$ and its corresponding binary equivalent $11101001_{2}$ are interpreted respectively as:

$$2\times10^{2} + 3\times10^{1} + 3\times10^{0}$$

$$1\times2^{7} + 1\times2^{6} + 1\times2^{5} + 0\times2^{4} + 1\times2^{3} + 0\times2^{2} + 0\times2^{1} + 1\times2^{0}$$

But how can we easily convert integer values into binary numbers? The answer is an algorithm called Divide by 2 that uses a stack to keep track of the digits for the binary result!

The Divide by 2 algorithm assumes that we start with an integer greater than 0. A simple iteration then continually divides the decimal number by 2 and keeps track of the remainder.

The first division by 2 gives information as to whether the value is even or odd. An even value will have the digit 0 in the ones place. An odd value will have the digit 1 in the ones place. We think about building our binary number as a sequence of digits; the first remainder we compute will actually be the last digit in the sequence

No description has been provided for this image

We again see the reversal property that signals that a stack is likely to be the appropriate data structure for solving the problem!

The C++ code below implements the Divide by 2 algorithm. The function divide_by_2() takes an argument that is a decimal number and repeatedly divides it by 2:

The C++ code below implements the Divide by 2 algorithm using an STL stack:

In [6]:
#include <iostream>
#include <stack>
#include <string>
using namespace std;

string divideBy2(int decimalNum) {
    stack<int> remStack;
    while (decimalNum > 0) {
        remStack.push(decimalNum % 2);
        decimalNum = decimalNum / 2;
    }
    string binString = "";
    while (!remStack.empty()) {
        binString += to_string(remStack.top());
        remStack.pop();
    }
    return binString;
}

int main() {
    cout << divideBy2(42) << " " << divideBy2(31) << endl;
    return 0;
}

101010 11111

Expected: 101010 11111.

It can easily be extended to perform the conversion for any base. In computer science it is common to use a number of different encodings. The most common of these are binary, octal (base 8), and hexadecimal (base 16).

The decimal number $233_{10}$ and its corresponding octal and hexadecimal equivalents $351_{8}$ and $E9_{16}$ are interpreted as

$$3\times8^{2} + 5\times8^{1} + 1\times8^{0}$$

$$14\times16^{1} + 9\times16^{0}$$

The function divide_by_2() can be modified to accept not only a decimal value but also a base for the intended conversion. The "Divide by 2" idea is simply replaced with a more general "Divide by base."

Base 2 through base 10 numbers need a maximum of 10 digits, so the typical digit characters 0 through 9 work fine.

The problem comes when we go beyond base 10. We can no longer simply use the remainders, as they are themselves represented as two-digit decimal numbers. Instead we need to create a set of digits that can be used to represent those remainders beyond 9

In [7]:
#include <iostream>
#include <stack>
using namespace std;

string baseConverter(int decimalNum, int base) {
    string digits = "0123456789ABCDEF";
    stack<int> remStack;
    while (decimalNum > 0) {
        remStack.push(decimalNum % base);  decimalNum /= base;
    }
    string newString = "";
    while (!remStack.empty()) {
        newString += digits[remStack.top()];  remStack.pop();
    }
    return newString;
}

int main() {
    cout << baseConverter(25, 2) << " " << baseConverter(25, 16) << endl;
    return 0;
}

11001 19

Expected: 11001 19 — the same value in binary and hexadecimal.

In [ ]:
display_quiz(path+"convert.json", max_width=800)

3.9. Infix, Prefix, and Postfix Expressions¶

When you write an arithmetic expression such as B · C, the form of the expression provides you with information so that you can interpret it correctly.

In this case we know that the variable B is being multiplied by the variable C since the multiplication operator · appears between them in the expression. This type of notation is referred to as infix since the operator is in between the two operands that it is working on.

Consider another infix example, A + B · C. The operators + and · still appear between the operands, but there is a problem. Which operands do they work on?

Does the + work on A and B, or does the · take B and C? The expression seems ambiguous. In fact, we know something about the operators + and ·. Each operator has a precedence level. Operators of higher precedence are used before operators of lower precedence.

The only thing that can change that order is the presence of parentheses. The precedence order for arithmetic operators places multiplication and division above addition and subtraction. If two operators of equal precedence appear, then a left-to-right ordering or associativity is used.

Let's interpret the troublesome expression A + B · C using operator precedence. B and C are multiplied first, and A is then added to that result. (A + B) · C would force the addition of A and B to be done first before the multiplication. In the expression A + B + C, by precedence (via associativity), the leftmost + would be done first.

Computers need to know exactly what operations to perform and in what order.

One way to write an expression that guarantees there will be no confusion with respect to the order of operations is to create what is called a fully parenthesized expression. This type of expression uses one pair of parentheses for each operator. The parentheses dictate the order of operations; there is no ambiguity!

The expression A + B · C + D can be rewritten as ((A + (B · C)) + D) to show that the multiplication happens first, followed by the leftmost addition. A + B + C + D can be written as (((A + B) + C) + D) since the addition operations associate from left to right.

There are two other very important expression formats that may not seem obvious to you at first. Consider the infix expression A + B. What would happen if we moved the operator before the two operands? The resulting expression would be + A B. Likewise, we could move the operator to the end, resulting in A B +. These look a bit strange.

These changes to the position of the operator with respect to the operands create two new expression formats, prefix and postfix. Prefix expression notation requires that all operators precede the two operands that they work on. Postfix, on the other hand, requires that its operators come after the corresponding operands.

A + B · C would be written as + A · B C in prefix. The multiplication operator comes immediately before the operands B and C, denoting that · has precedence over +. The addition operator then appears before the A and the result of the multiplication.

In postfix, the expression would be A B C · +. Again, the order of operations is preserved since the · appears immediately after the B and the C, denoting that · has precedence, with + coming after.

Now consider the infix expression (A + B) · C. Recall that in this case, infix requires the parentheses to force the performance of the addition before the multiplication.

However, when A + B was written in prefix, the addition operator was simply moved before the operands, + A B. The result of this operation becomes the first operand for the multiplication. The multiplication operator is moved in front of the entire expression, giving us · + A B C. Likewise, in postfix A B + forces the addition to happen first:

Infix Expression Prefix Expression Postfix Expression
A + B + A B A B +
A + B · C + A · B C A B C · +
(A + B) · C · + A B C A B + C ·
A + B · C + D + + A · B C D A B C · + D +
(A + B) · (C + D) · + A B + C D A B + C D + ·
A · B + C · D + · A B · C D A B · C D · +
A + B + C + D + + + A B C D A B + C + D +

3.9.1 Conversion of Infix Expressions to Prefix and Postfix¶

The first technique that we will consider uses the notion of a fully parenthesized expression that was discussed earlier.

Recall that A + B · C can be written as (A + (B · C)) to show explicitly that the multiplication has precedence over the addition. Look at the right parenthesis in the subexpression (B · C) above. If we were to move the multiplication symbol to that position and remove the matching left parenthesis, giving us B C ·, we would in effect have converted the subexpression to postfix notation.

If the addition operator were also moved to its corresponding right parenthesis position and the matching left parenthesis were removed, the complete postfix expression would result:

No description has been provided for this image

If we do the same thing but instead of moving the symbol to the position of the right, we move it to the left parenthesis, we get prefix notation. The position of the parenthesis pair is actually a clue to the final position of the enclosed operator.

No description has been provided for this image

So in order to convert an expression, no matter how complex, to either prefix or postfix notation, fully parenthesize the expression using the order of operations. Then move the enclosed operator to the position of either the left or the right parenthesis depending on whether you want prefix or postfix notation:

No description has been provided for this image

3.9.2. General Infix-to-Postfix Conversion¶

Consider once again the expression A + B · C. As shown above, A B C · + is the postfix equivalent. We have already noted that the operands A, B, and C stay in their relative positions. It is only the operators that change position.

Let's look again at the operators in the infix expression. The first operator that appears from left to right is +. However, in the postfix expression, + is at the end since the next operator, ·, has precedence over addition. The order of the operators in the original expression is reversed in the resulting postfix expression.

As we process the expression, the operators have to be saved somewhere since their corresponding right operands are not seen yet. Also, the order of these saved operators may need to be reversed due to their precedence.

Because of this reversal of order, it makes sense to consider using a stack to keep the operators until they are needed! What about (A + B) · C? Recall that A B + C · is the postfix equivalent. Again, processing this infix expression from left to right, we see + first. In this case, when we see ·, + has already been placed in the result expression because it has precedence over · by virtue of the parentheses.

The conversion algorithm works by saving a left parenthesis to indicate the upcoming arrival of a high-precedence operator, which waits for the corresponding right parenthesis to determine its position. When the right parenthesis appears, the operator is popped from the stack.

As we scan the infix expression from left to right, we use a stack to hold operators, reversing their order as in the first example. The stack's top always holds the most recently saved operator, and when reading a new operator, we compare its precedence with those already on the stack.

Assume the infix expression is a string of tokens delimited by spaces. The operator tokens are ·, /, +, and -, along with the left and right parentheses, ( and ). The following steps will produce a string of tokens in postfix order:

  1. Create an empty stack (op_stack) for operators and an empty list for output.
  2. Convert the input infix string to a list using the string method split().
  1. Scan the token list from left to right:
    • Append operands to the output list.
    • Push left parentheses onto op_stack.
    • For right parentheses, pop op_stack until the left parenthesis is removed, appending each operator to the output list.
    • Push operators (·, /, +, -) onto op_stack after removing any higher or equal precedence operators from the stack and appending them to the output list.
  1. After processing the input expression, remove any remaining operators from op_stack and append them to the output list.
No description has been provided for this image

The core of the algorithm — token by token, operands go straight to the output while operators wait on the stack until an operator of lower precedence arrives:

for (string token : tokenList) {
    if (isalnum(token[0])) {
        postfixList.push_back(token);
    } else if (token == "(") {
        opStack.push(token);
    } else if (token == ")") {
        while (!opStack.empty() && opStack.top() != "(") {
            postfixList.push_back(opStack.top());
            opStack.pop();
        }
        opStack.pop();   // pop the "("
    } else {
        while (!opStack.empty() && prec[opStack.top()] >= prec[token]) {
            postfixList.push_back(opStack.top());
            opStack.pop();
        }
        opStack.push(token);
    }
}

Tokens are extracted from the input with a stringstream, C++'s counterpart of Python's split(). The complete program:

In [8]:
#include <iostream>
#include "dscpp/expression.hpp"   // the complete converter (md listing above)
using namespace std;

int main() {
    cout << infixToPostfix("A * B + C * D") << endl;
    cout << infixToPostfix("( A + B ) * C - ( D - E ) * ( F + G )") << endl;
    return 0;
}

A B * C D * +

A B + C * D E - F G + * -

Expected:

A B * C D * +
A B + C * D E - F G + * -

3.9.3. Postfix Evaluation¶

We will now consider the evaluation of an expression that is already in postfix notation. In this case, a stack is again the data structure of choice. However, as you scan the postfix expression, it is the operands that must wait, not the operators as in the conversion algorithm above.

Another way to think about the solution is that whenever an operator is seen on the input, the two most recent operands will be used in the evaluation.

To see this in more detail, consider the postfix expression 4 5 6 · +. As you scan the expression from left to right, you first encounter the operands 4 and 5. At this point, you are still unsure what to do with them until you see the next symbol. Placing each on the stack ensures that they are available if an operator comes next.

No description has been provided for this image

A slightly more complex example, 7 8 + 3 2 + /. First, the stack size grows, shrinks, and then grows again as the subexpressions are evaluated. Second, the division operation needs to be handled carefully. Recall that the operands in the postfix expression are in their original order since postfix changes only the placement of operators. When the operands for the division are popped from the stack, they are reversed. Since division is not a commutative operator, we must be sure that the order of the operands is not switched!

No description has been provided for this image
  1. Create an empty stack called operand_stack.
  2. Convert the string to a list by using the string method split().
  1. Scan the token list from left to right.
    • If the token is an operand, convert it from a string to an integer and push the value onto the operand_stack.
    • If the token is an operator, ·, /, +, or -, it will need two operands. Pop the operand_stack twice. The first pop is the second operand and the second pop is the first operand. Perform the arithmetic operation. Push the result back on the operand_stack.
  2. When the input expression has been completely processed, the result is on the stack. Pop the operand_stack and return the value.

To assist with the arithmetic, a helper function do_math() is defined:

To assist with the arithmetic, a helper function doMath() is defined:

double doMath(string op, double op1, double op2) {
    if (op == "*") {
        return op1 * op2;
    } else if (op == "/") {
        return op1 / op2;
    } else if (op == "+") {
        return op1 + op2;
    } else {
        return op1 - op2;
    }
}
In [9]:
#include <iostream>
#include "dscpp/expression.hpp"   // doMath + postfixEval
using namespace std;

int main() {
    cout << postfixEval("7 8 + 3 2 + /") << endl;
    return 0;
}

3

Expected: 3 — that is $(7+8)\,/\,(3+2)$.

It is important to note that in both the postfix conversion and the postfix evaluation programs we assumed that there were no errors in the input expression.

In [ ]:
display_quiz(path+"c1.json", max_width=800)
In [ ]:
display_quiz(path+"c2.json", max_width=800)

Exercise: Modify the infix_to_postfix() function so that it can convert the following expression: 5 * 3 ^ (4 - 2). Run the function on the expression and paste the answer here:¶

In [10]:
#include <iostream>
#include <stack>
#include <vector>
#include <map>
#include <sstream>
#include <string>
#include <cctype>
using namespace std;

string infixToPostfix(string infixExpr) {
    // Your code here: add support for the right-associative ^ operator

    return result;
}

int main() {
    cout << infixToPostfix("5 * 3 ^ ( 4 - 2 )") << endl;
    return 0;
}
/tmp/tmpnuhnarzj.cpp: In function ‘std::string infixToPostfix(std::string)’:
/tmp/tmpnuhnarzj.cpp:15:12: error: ‘result’ was not declared in this scope
   15 |     return result;
      |            ^~~~~~
[C++ kernel] Error: Unable to compile the source code. Return error: 0x1.

Hint: give ^ the highest precedence, and because it is right-associative, pop waiting operators only while their precedence is strictly greater than the incoming ^.

Expected: 5 3 4 2 - ^ *, which evaluates to 45.

3.10. Queues¶

A queue is an ordered collection of items where the addition of new items happens at one end, called the rear, and the removal of existing items occurs at the other end, commonly called the front. As an element enters the queue it starts at the rear and makes its way toward the front, waiting until that time when it is the next element to be removed. The item that has been in the collection the longest is at the front. This ordering principle is sometimes called FIFO, first in, first out.

The simplest example of a queue is that we wait in a line for a movie, we wait in the checkout line at a grocery store, and we wait in the cafeteria line.

Well-behaved lines, or queues, are very restrictive in that they have only one way in and only one way out. There is no jumping in the middle and no leaving before you have waited the necessary amount of time to get to the front.

Computer science also has common examples of queues. Figure shows a simple queue of data objects.

No description has been provided for this image

Operating systems use a number of different queues to control processes within a computer. The scheduling of what gets done next is typically based on a queuing algorithm that tries to execute programs as quickly as possible and serve as many users as it can.

3.11 The Queue Abstract Data Type¶

The queue operations are given below:

  • Queue() creates a new queue that is empty. It needs no parameters and returns an empty queue.
  • enqueue(item) adds a new item to the rear of the queue. It needs the item and returns nothing.
  • dequeue() removes the front item from the queue. It needs no parameters and returns the item. The queue is modified.
  • is_empty() tests to see whether the queue is empty. It needs no parameters and returns a boolean value.
  • size() returns the number of items in the queue. It needs no parameters and returns an integer.

Table below shows the results of a sequence of queue operations. The queue contents are shown such that the front is on the right.

Queue Operation Queue Contents Return Value
q.is_empty() [] True
q.enqueue(4) [4]
q.enqueue("dog") ['dog', 4]
q.enqueue(True) [True, 'dog', 4]
q.size() [True, 'dog', 4] 3
q.is_empty() [True, 'dog', 4] False
q.enqueue(8.4) [8.4, True, 'dog', 4]
q.dequeue() [8.4, True, 'dog'] 4
q.dequeue() [8.4, True] 'dog'
q.size() [8.4, True] 2

3.12 Implementing a Queue in C++¶

The implementation assumes that the rear is at position 0 in the underlying vector. This allows us to insert at the front of the vector for enqueue and pop_back for dequeue (the complete class is in pythonds3/cppds/basic/queue.cpp):

template <typename T>
class Queue {
    private:
        vector<T> items;

    public:
        bool isEmpty() {
            return items.empty();
        }
        void enqueue(T item) {
            items.insert(items.begin(), item);   // rear at position 0
        }
        T dequeue() {
            T front = items.back();
            items.pop_back();
            return front;
        }
        int size() {
            return items.size();
        }
};
In [11]:
#include <iostream>
#include "dscpp/queue.hpp"   // the vector-backed Queue of this section
using namespace std;

int main() {
    Queue<string> q;
    q.enqueue("4");
    q.enqueue("dog");
    q.enqueue("true");
    cout << q.size() << endl;
    cout << boolalpha << q.isEmpty() << endl;
    cout << q.dequeue() << endl;
    return 0;
}

3

false

4

In [ ]:
display_quiz(path+"queue.json", max_width=800)

3.13. Queue Simulation: Hot Potato¶

To begin, let's consider the children's game hot potato. In this game, children line up in a circle and pass an item from neighbor to neighbor as fast as they can. At a certain point in the game, the action is stopped and the child who has the item (the potato) is removed from the circle. Play continues until only one child is left.

No description has been provided for this image

We will implement a general simulation of Hot Potato. Our program will input a list of names and a constant, call it "num," to be used for counting. It will return the name of the last person remaining after repetitive counting by num.

To simulate the circle, we will use a queue. Assume that the child holding the potato will be at the front of the queue. Upon passing the potato, the simulation will simply dequeue and then immediately enqueue that child, putting them at the end of the line.

No description has been provided for this image

After num dequeue/enqueue operations, the child at the front will be removed permanently and another cycle will begin. This process will continue until only one name remains (the size of the queue is 1)!

In [12]:
#include <iostream>
#include <queue>
#include <vector>
using namespace std;

// STL queue: front() reads, pop() removes (returns nothing)
string hotPotato(vector<string> nameList, int num) {
    queue<string> simQueue;
    for (string name : nameList) simQueue.push(name);
    while (simQueue.size() > 1) {
        for (int i = 0; i < num; i++) {
            simQueue.push(simQueue.front());
            simQueue.pop();
        }
        simQueue.pop();
    }
    return simQueue.front();
}

int main() {
    cout << hotPotato({"Bill", "David", "Susan", "Jane", "Kent", "Brad"}, 7) << endl;
    return 0;
}

Susan

Expected: Susan survives the game.

Notice that the list is loaded into the queue such that the first name on the list will be at the front of the queue. 'Bill' in this case is the first item in the list and therefore moves to the front of the queue.

Exercise: Implement the queue ADT, using a list such that the rear of the queue is at the end of the list.¶

In [13]:
// Your code here

3.14. Queue Simulation: Printing Tasks¶

Assume students send printing tasks to the shared printer in the lab, the tasks are placed in a queue to be processed in a first come, first served manner.

On any average day about 10 students are working in the lab at any given hour. These students typically print up to twice during that time, and the length of these tasks ranges from 1 to 20 pages.

The printer in the lab is older, capable of processing 10 pages per minute. The printer could be switched to give better quality, but then it would produce only five pages per minute. The slower printing speed could make students wait too long. What page rate should be used?

We will need to construct representations for students, printing tasks, and the printer. As students submit printing tasks, we will add them to a waiting list, a queue of print tasks attached to the printer. When the printer completes a task, it will look at the queue to see if there are any remaining tasks to process.

Of interest for us is the average amount of time students will wait for their papers to be printed. This is equal to the average amount of time a task waits in the queue.

No description has been provided for this image

To model this situation we need to use some probabilities. For example, students may print a paper from 1 to 20 pages in length. This could mean that there is equal chance of any length from 1 to 20 appearing.

If there are 10 students in the lab and each prints twice, then there are 20 print tasks per hour on average. What is the chance that at any given second, a print task is going to be created?

The way to answer this is to consider the ratio of tasks to time. Twenty tasks per hour means that on average there will be one task every 180 seconds:

$$\frac {20\ tasks}{1\ hour} \times \frac {1\ hour} {60\ minutes} \times \frac {1\ minute} {60\ seconds}=\frac {1\ task} {180\ seconds}$$

For every second we can simulate the chance that a print task occurs by generating a random number between 1 and 180 inclusive. If the number is 180, we say a task has been created. Note that it is possible that many tasks could be created in a row or we may wait quite a while for a task to appear. That is the nature of simulation.

3.14.1. Main Simulation Steps¶

  1. Create a queue of print tasks. Each task will be given a timestamp upon its arrival. The queue is empty to start.

  2. For each second (current_second):

    • Does a new print task get created? If so, add it to the queue with the current_second as the timestamp.
    • If the printer is not busy and if a task is waiting,
      • Remove the next task from the print queue and assign it to the printer.
      • Subtract the timestamp from the current_second to compute the waiting time for that task.
      • Append the waiting time for that task to a list for later processing.
      • Based on the number of pages in the print task, figure out how much time will be required.
    • The printer now does one second of printing if necessary. It also subtracts one second from the time required for that task.
    • If the task has been completed, in other words the time required has reached zero, the printer is no longer busy.
  1. After the simulation is complete, compute the average waiting time from the list of waiting times generated.

3.14.2. Python Implementation¶

To design this simulation we will create classes for the three real-world objects described above: Printer, Task, and PrintQueue.

class Printer {
    private:
        int pageRate;
        Task* currentTask;
        double timeRemaining;

    public:
        Printer(int ppm) {
            pageRate = ppm;
            currentTask = NULL;
            timeRemaining = 0;
        }
        void tick() {
            if (currentTask != NULL) {
                timeRemaining -= 1;
                if (timeRemaining <= 0) {
                    currentTask = NULL;
                }
            }
        }
        bool busy() {
            return currentTask != NULL;
        }
        void startNext(Task* newTask) {
            currentTask = newTask;
            timeRemaining = newTask->getPages() * 60.0 / pageRate;
        }
};

The tick() method decrements the internal timer and sets the printer to idle (line 11) if the task is completed. When the task is created, a random number generator will provide a length from 1 to 20 pages. We have chosen to use the randrange() function from the random() module.

Each task will also need to keep a timestamp to be used for computing waiting time. This timestamp will represent the time that the task was created and placed in the printer queue.

class Task {
    private:
        int timestamp;
        int pages;

    public:
        Task(int time) {
            timestamp = time;
            pages = rand() % 20 + 1;
        }
        int getStamp() { return timestamp; }
        int getPages() { return pages; }
        int waitTime(int currentTime) {
            return currentTime - timestamp;
        }
};

The print_queue object is an instance of our existing queue ADT.

void simulation(int numSeconds, int pagesPerMinute) {
    Printer labPrinter(pagesPerMinute);
    queue<Task*> printQueue;
    vector<int> waitingTimes;

    for (int currentSecond = 0; currentSecond < numSeconds; currentSecond++) {
        if (newPrintTask()) {
            printQueue.push(new Task(currentSecond));
        }

        if (!labPrinter.busy() && !printQueue.empty()) {
            Task* nextTask = printQueue.front();
            printQueue.pop();
            waitingTimes.push_back(nextTask->waitTime(currentSecond));
            labPrinter.startNext(nextTask);
        }

        labPrinter.tick();
    }

    double sum = 0;
    for (int w : waitingTimes) sum += w;
    printf("Average Wait %6.2f secs %3d tasks remaining.\n",
           sum / waitingTimes.size(), (int)printQueue.size());
}

A boolean helper function, new_print_task(), decides whether a new printing task has been created. We have again chosen to use the randrange function from the random module to return a random integer between 1 and 180:

In [14]:
#include <iostream>
#include <queue>
#include <vector>
#include <cstdlib>
#include <cstdio>
using namespace std;

class Task {
    private:
        int timestamp;
        int pages;
    public:
        Task(int time) { timestamp = time; pages = rand() % 20 + 1; }
        int getPages() { return pages; }
        int waitTime(int currentTime) { return currentTime - timestamp; }
};

class Printer {
    private:
        int pageRate;
        Task* currentTask;
        double timeRemaining;
    public:
        Printer(int ppm) { pageRate = ppm; currentTask = NULL; timeRemaining = 0; }
        void tick() {
            if (currentTask != NULL) {
                timeRemaining -= 1;
                if (timeRemaining <= 0) { delete currentTask; currentTask = NULL; }
            }
        }
        bool busy() { return currentTask != NULL; }
        void startNext(Task* newTask) {
            currentTask = newTask;
            timeRemaining = newTask->getPages() * 60.0 / pageRate;
        }
};

bool newPrintTask() {
    return rand() % 180 + 1 == 180;
}

void simulation(int numSeconds, int pagesPerMinute) {
    Printer labPrinter(pagesPerMinute);
    queue<Task*> printQueue;
    vector<int> waitingTimes;

    for (int currentSecond = 0; currentSecond < numSeconds; currentSecond++) {
        if (newPrintTask()) {
            printQueue.push(new Task(currentSecond));
        }
        if (!labPrinter.busy() && !printQueue.empty()) {
            Task* nextTask = printQueue.front();
            printQueue.pop();
            waitingTimes.push_back(nextTask->waitTime(currentSecond));
            labPrinter.startNext(nextTask);
        }
        labPrinter.tick();
    }

    double sum = 0;
    for (int w : waitingTimes) sum += w;
    printf("Average Wait %6.2f secs %3d tasks remaining.\n",
           waitingTimes.empty() ? 0 : sum / waitingTimes.size(), (int)printQueue.size());
}

int main() {
    srand(42);
    cout << "pages per minute = 5:" << endl;
    for (int i = 0; i < 10; i++) simulation(3600, 5);

    cout << "pages per minute = 10:" << endl;
    for (int i = 0; i < 10; i++) simulation(3600, 10);
    return 0;
}

pages per minute = 5:

Average Wait 215.91 secs 0 tasks remaining.

Average Wait 130.50 secs 1 tasks remaining.

Average Wait 55.45 secs 0 tasks remaining.

Average Wait 64.58 secs 0 tasks remaining.

Average Wait 114.26 secs 0 tasks remaining.

Average Wait 166.00 secs 0 tasks remaining.

Average Wait 100.50 secs 2 tasks remaining.

Average Wait 152.41 secs 0 tasks remaining.

Average Wait 108.53 secs 2 tasks remaining.

Average Wait 340.14 secs 4 tasks remaining.

pages per minute = 10:

Average Wait 15.11 secs 0 tasks remaining.

Average Wait 14.85 secs 0 tasks remaining.

Average Wait 4.64 secs 0 tasks remaining.

Average Wait 65.68 secs 0 tasks remaining.

Average Wait 49.50 secs 0 tasks remaining.

Average Wait 32.50 secs 0 tasks remaining.

Average Wait 8.00 secs 0 tasks remaining.

Average Wait 10.00 secs 0 tasks remaining.

Average Wait 14.80 secs 0 tasks remaining.

Average Wait 31.80 secs 0 tasks remaining.

We run the simulation for a period of 60 minutes (3,600 seconds) using a page rate of five pages per minute. In addition, we run 10 independent trials. Remember that because the simulation works with random numbers each run will return different results.

You can see that there is a large variation in the average wait time. Now we will adjust the page rate to 10 pages per minute and run the 10 trials again. With a faster page rate, our hope would be that more tasks would be completed in the one-hour time frame.

With the faster printer (10 pages per minute) the average wait drops sharply and no tasks are left in the queue — the same conclusion as the textbook's Python experiment.

3.14.3. Discussion¶

We were trying to answer a question about whether the current printer could handle the task load if it were set to print with a better quality but slower page rate. The approach we took was to write a simulation that modeled the printing tasks as random events of various lengths and arrival times.

The output above shows that with 5 pages per minute printing, the average waiting time varied a lot. With a faster printing rate, the low value was 1 second with a high of only 28.

Therefore, we are perhaps persuaded that slowing the printer down to get better quality may not be a good idea. Students cannot afford to wait that long for their papers, especially when they need to be getting on to their next class. A six-minute wait would simply be too long!

We can simulate any number of interesting behaviors. For example,

  • What if enrollment goes up and the average number of students increases by 20?
  • What if it is Saturday and students do not need to get to class? Can they afford to wait?
  • What if the size of the average print task decreases since Python is such a powerful language and programs tend to be much shorter?

These questions could all be answered by modifying the above simulation. However, it is important to remember that the simulation is only as good as the assumptions that are used to build it.

3.15 Deques¶

A deque, also known as a double-ended queue, is an ordered collection of items similar to the queue. It has two ends, a front and a rear. What makes a deque different is the unrestrictive nature of adding and removing items.

No description has been provided for this image

New items can be added at either the front or the rear. Likewise, existing items can be removed from either end. In a sense, this hybrid linear structure provides all the capabilities of stacks and queues in a single data structure.

3.16 The Deque Abstract Data Type¶

The deque operations are given below:

  • Deque() creates a new deque that is empty. It needs no parameters and returns an empty deque.
  • add_front(item) adds a new item to the front of the deque. It needs the item and returns nothing.
  • add_rear(item) adds a new item to the rear of the deque. It needs the item and returns nothing.
  • remove_front() removes the front item from the deque. It needs no parameters and returns the item. The deque is modified.
  • remove_rear() removes the rear item from the deque. It needs no parameters and returns the item. The deque is modified.
  • is_empty() tests to see whether the deque is empty. It needs no parameters and returns a boolean value.
  • size() returns the number of items in the deque. It needs no parameters and returns an integer.

The contents in front are listed on the right. It is very important to keep track of the front and the rear as you move items in and out of the collection as things can get a bit confusing.

Deque Operation Deque Contents Return Value
d.is_empty() [] True
d.add_rear(4) [4]
d.add_rear("dog") ['dog', 4]
d.add_front("cat") ['dog', 4, 'cat']
d.add_front(True) ['dog', 4, 'cat', True]
d.size() ['dog', 4, 'cat', True] 4
d.is_empty() ['dog', 4, 'cat', True] False
d.add_rear(8.4) [8.4, 'dog', 4, 'cat', True]
d.remove_rear() ['dog', 4, 'cat', True] 8.4
d.remove_front() ['dog', 4, 'cat'] True

3.17. Implementing a Deque in C++¶

Our implementation will assume that the rear of the deque is at position 0 in the vector (the complete class is in pythonds3/cppds/basic/deque.cpp):

template <typename T>
class Deque {
    private:
        vector<T> items;

    public:
        bool isEmpty() {
            return items.empty();
        }
        void addFront(T item) {
            items.push_back(item);
        }
        void addRear(T item) {
            items.insert(items.begin(), item);
        }
};
T removeFront() {
    T front = items.back();
    items.pop_back();
    return front;
}
T removeRear() {
    T rear = items.front();
    items.erase(items.begin());
    return rear;
}
int size() {
    return items.size();
}

(complete class: dscpp/deque.hpp)

You can see many similarities to the C++ code already described for stacks and queues. The STL also ships a ready-made std::deque with push_front/push_back/pop_front/pop_back — we use it below.

3.18 Palindrome Checker¶

A palindrome is a string that reads the same forward and backward, for example, radar, toot, and madam. We would like to construct an algorithm to input a string of characters and check whether it is a palindrome.

The solution to this problem will use a deque to store the characters of the string. We will process the string from left to right and add each character to the rear of the deque. At this point, the deque will be acting very much like an ordinary queue.

However, we can now make use of the dual functionality of the deque. The front of the deque will hold the first character of the string and the rear of the deque will hold the last character.

No description has been provided for this image

Since we can remove both of the front and rear characters directly, we can compare them and continue only if they match. If we can keep matching first and the last items, we will eventually either run out of characters or be left with a deque of size 1 depending on whether the length of the original string was even or odd. In either case, the string must be a palindrome.

In [15]:
#include <iostream>
#include <deque>
#include <string>
using namespace std;

bool palChecker(string aString) {
    deque<char> charDeque;
    for (char ch : aString) charDeque.push_back(ch);   // add to the rear
    while (charDeque.size() > 1) {
        char first = charDeque.front(); charDeque.pop_front();
        char last = charDeque.back(); charDeque.pop_back();
        if (first != last) return false;
    }
    return true;
}

int main() {
    cout << boolalpha;
    cout << palChecker("lsdkjfskf") << endl;   // false
    cout << palChecker("radar") << endl;       // true
    return 0;
}

false

true

Expected: false then true.

In [ ]:
from jupytercards import display_flashcards
fpath = "flashcards/"
display_flashcards(fpath + 'ch5.json')

References¶

  1. Textbook: Problem Solving with Algorithms and Data Structures using C++ (cppds), Chapter 3 (Linear Structures) — https://runestone.academy/ns/books/published/cppds/index.html

Key terms¶

Here's a brief definition for each of the key terms:

  • Linear ADT: An abstract data type where data elements are arranged in a sequential order, and each member element is connected to its previous and next element in a single level. Examples include stacks and queues.

  • Stack: A linear data structure that follows a particular order in which operations are performed. The order is Last In First Out (LIFO), meaning the element added last is the first one to be removed.

  • Front: In the context of queues, the start of the queue where the first element resides and from which elements are removed during the dequeue operation.

  • Rear: In the context of queues, the end of the queue where new elements are added during the enqueue operation.

  • Top: In the context of stacks, the last added element and the current endpoint of the structure. It is the first element to be removed due to the LIFO policy.

  • Base: In the context of stacks, the bottom of the stack where the first inserted element resides.

  • LIFO (Last In First Out): A principle used primarily in stacks where the last element added to the structure is the first one to be removed.

  • Infix: A notation in which operators are written in-between their operands, such as the expression A + B.

  • Prefix: A notation in which operators precede their operands. For example, the infix expression A + B would be written as +AB.

  • Postfix: A notation in which operators follow their operands. For example, the infix expression A + B would be written as AB+.

  • Fully Parenthesized Expression: An expression where every binary or unary operation is explicitly enclosed in parentheses to specify the order of operations without ambiguity.

  • Queue: A linear data structure that follows the First In First Out (FIFO) principle, where the first element added is the first one to be removed.

  • FIFO (First In First Out): A principle used primarily in queues where the first element added to the structure is the first one to be removed.

  • Deque: Short for double-ended queue, it is a data structure that allows the insertion and removal of elements from both the front and the rear, providing more flexibility than standard queues or stacks.