munotes®

Computer Science Practical 3 Notes | B.Sc. (Computer Science) Semester 3 | Mumbai University | munotes

Get access to whole semester resourcesSemester Pass

Official Notes munotes.in

Computer Science Practical 3

B.SC. (COMPUTER SCIENCE) · SEMESTER 3

Strictly as per the University of Mumbai NEP syllabus in force for B.Sc. (Computer Science)

For B.Sc. (Computer Science) students of the University of Mumbai and all its affiliated colleges

Open the book ↓

munotes.in Second Year

Computer Science Practical 3

Copyright © 2026 munotes.in. All rights reserved.

Written and first published by munotes.in, 2026.

This book is free for individual students to read at munotes.in. No part of it may be reproduced, distributed, stored, translated or used for institutional or classroom purposes in any form without a prior written licence from munotes.in.

Licensing and permissions: contact@munotes.in

The text of statutes and of judgments reproduced in this book is in the public domain under section 52(1)(q) of the Copyright Act 1957. The commentary, arrangement, examples and questions are the original work of munotes.in.

munotes.in is an independent study resource for MU students. It is not affiliated with, endorsed by, or officially connected to the University of Mumbai. Course names and university references describe the students and syllabus the material relates to.

munotes.in

Contents

Module I Principles of Operating Systems: ten exercises in C on Linux

  1. How This Practical Is Examined: the Journal, the 80 Per Cent Rule and the Two Questions 1
  2. The Laboratory from Zero: gcc, a Program, and the Manual 6
  3. Processes: fork, wait, exec, and Why Two Programs Need to Talk 14
  4. Practical 1: Process Communication using Shared Memory 22
  5. Practical 1 continued: the Race Condition, Semaphores, and Producer and Consumer 31
  6. Practical 2: Process Communication with Pipes 40
  7. Practical 2 continued: Message Queues, Blocking and Non-blocking 48
  8. Practical 3: Threading and Single Thread Control Flow 57
  9. Practical 4: Multi-threading and Fibonacci Generation 67
  10. Practical 5: Process Synchronisation and the Bounded Buffer 77
  11. Practical 6: the Readers-Writers Problem 86
  12. Practical 7: CPU Scheduling, FCFS and Non-preemptive Scheduling 95
  13. Practical 8: CPU Scheduling, Round Robin 106
  14. Practical 9: Memory Management, FIFO and LRU Page Replacement 117
  15. Practical 10: Disk Scheduling 128
  16. Practical 10 continued: a Simple File System 137

Module II Data Structures: ten exercises in Python

  1. Python for Data Structures: the Tools This Module Uses 146
  2. Practical 11: Abstract Data Types and Custom Structures 154
  3. Practical 12: Singly Linked Lists 162
  4. Practical 13: Polynomial Operations Using Linked Lists 172
  5. Practical 14: Doubly Linked Lists 181
  6. Practical 15: the Stack ADT 190
  7. Practical 15 continued: Prefix to Postfix, and Evaluating It 198
  8. Practical 16: Queues and Circular Queues 207
  9. Practical 17: Binary Search Trees and Tree Traversals 217
  10. Practical 18: AVL Trees and Rebalancing 230
  11. Practical 18 continued: Heaps and Priority Queues 243
  12. Practical 19: Graph Representations and Traversals 258
  13. Practical 20: Hashing and Collision Handling 272

Module J The journal and the practical examination

  1. Keeping the Journal, and What Goes on the Page 286
  2. A Worked Practical Paper: Q.1 and Q.2 293
munotes.in

Module I

Principles of Operating Systems: ten exercises in C on Linux

munotes.in

Chapter One

How This Practical Is Examined: the Journal, the 80 Per Cent Rule and the Two Questions

Syllabus topic MU's evaluation scheme for practical courses, and rows 2 to 8 of her particulars table for this paper

In one line

This paper is worth 50 marks and you never write a theory answer for any of them: 20 come from your laboratory work and your journal during the term, and 30 come from two hours at a computer at the end of it, answering one question on Module 1 and one on Module 2.

In the wording a student can write down: Computer Science Practical 3 is a Major practical course of 2 credits and 60 hours, assessed 40 per cent internally and 60 per cent by a Semester End Practical Examination of two hours, in which Q.1 is set on Module 1 for 15 marks and Q.2 on Module 2 for 15 marks.

Why this chapter comes first

Because marks are lost here before a single program is written.

A student who does not keep a journal cannot sit the examination at all. A student who has done fifteen of the twenty practicals has not met the University's own minimum. And a student who has been told by a senior that there is a viva worth 6 marks will spend the last week of term learning definitions instead of learning to type a working program in an hour, which is the only thing this paper actually tests.

So: read the numbers, then start on Module 1.

What MU says this paper is

Read off her particulars table for the course.

RowParticularWhat she prints
2VerticalMajor
3TypePractical
4Credits2 credits, 1 credit = 30 hours of practical work in a semester
5Hours Allotted60 hours
6Marks Allotted50 Marks
12SplitInternal Continuous Assessment: 40%, Semester End Examination: 60%

Two things in that table are worth a second look.

Sixty hours is the biggest single block of contact time in the semester. A 2-credit theory paper is 30 hours, because a theory credit is 15 hours of lectures. A practical credit is 30 hours of laboratory work, so a 2-credit practical is 60. Principles of Operating Systems and Data Structures are 30 hours each; this one paper is as long as both of them together.

"Type: Practical" means there is no theory paper for it. No 1-hour written examination, no class tests on Module 1 and Module 2. Everything is the work and the machine.

The internal 20 marks, and the arithmetic behind them

Forty per cent of 50 is 20, and MU splits those 20 in two.

ComponentMarks
Practical assignments, experiments, hands-on tests, presentations, demonstrations, online class tests, case studies15
Journal5
Total20

The first line is a list of seven things and your college will use two or three of them. In most colleges it is the twenty practicals themselves: you do the practical, the teacher sees it running, you write it up, and a mark goes into a register. The second line is the journal as an object: is it complete, is it neat, is it signed.

munotes.in1

How This Practical Is Examined: the Journal, the 80 Per Cent Rule and the Two Questions

Note what is not in that list: there is no "attendance" component and no "internal test" that can rescue a term of missed practicals. The 15 marks are the work.

The certified journal, and the 80 per cent rule

MU prints two sentences under the practical paper pattern, and both are conditions on being allowed to sit the examination at all.

Certified Journal is compulsory for appearing at the time of Practical Exam

Minimum 80% practical are required to be completed

Certified means signed by the teacher who watched you do the work, and stamped by the department. An unsigned journal full of correct programs is not a certified journal.

Eighty per cent of twenty practicals is sixteen. There are twenty exercises in this paper, ten in Module 1 and ten in Module 2, so the rule says at least sixteen of them must be in your journal, completed. It does not say which sixteen, and it does not say eight from each module, but a journal with all ten of Module 2 and only six of Module 1 is a student who cannot answer Q.1, which is half the paper.

The safe reading, and the one every college applies: do all twenty. The four you are allowed to miss are there for the week you were ill, not for the four you found hard.

[Keeping the Journal, and What Goes on the Page] is the chapter on what one entry looks like.

The semester end examination

Sixty per cent of 50 is 30, and this is MU's printed pattern for it, word for word from her own table.

QuestionPractical question based onMarks
Q.1Module 115
Q.2Module 215

Two hours. Two questions. Thirty marks.

That is the whole paper, and three things follow from it that are worth saying plainly.

There is no viva question. The first year's practical papers, set under item 6.5 (R), print Q.1 for 12, Q.2 for 12 and a viva for 6. This paper, set under item 6.14 (N), prints no third question. An examiner at your machine will still ask you what your program does, because that is how a practical examination is conducted anywhere, and those questions feed the assessment of your work; but there is no separate question in the paper carrying marks for them.

Each question is worth half the paper. A student who is fluent in Module 2 and has never compiled a C program has a ceiling of 15 out of 30. The two modules are in two different languages and both of them have to work.

munotes.in2

How This Practical Is Examined: the Journal, the 80 Per Cent Rule and the Two Questions

An hour per question is not much. A bounded-buffer program with two semaphores and a mutex is about sixty lines. An AVL tree with all four rotations is about a hundred. You will not design either of them in the hall; you will remember them. That is what the journal is for, and it is why every chapter in this book prints the whole program rather than an outline of it.

What MU says you should be able to do

Her Course Objectives (CO) and Course Outcomes (OC) are the other half of the contract, and they are a fair list of what an examiner is looking for.

Course Objectives. To develop hands-on skills in implementing core concepts of operating systems and data structures. To simulate and solve real-world problems using process management, synchronization, and memory management. To strengthen understanding of data abstraction and manipulation using linked structures, trees, graphs, and hashing. To enable students to analyze and compare algorithmic strategies for CPU scheduling, buffer control, and structured data operations. To foster problem-solving abilities through coding, debugging, and testing.

Course Outcomes. After this course a student can design and implement solutions using inter-process communication techniques such as shared memory and message passing; apply multithreading, synchronization mechanisms, and scheduling algorithms; construct and manipulate linear and non-linear data structures using custom implementations; demonstrate effective use of stack, queue, trees, graphs, and hash tables; analyze and evaluate the performance of memory and disk management techniques; and apply the result to real-time, scalable system-level applications.

Read OC 5 again: analyze and evaluate the performance. Three of the ten exercises in Module 1 are about measuring something, not just making it work: hit and miss ratios in [Practical 9: Memory Management, FIFO and LRU Page Replacement], total head movement in [Practical 10: Disk Scheduling], and waiting and turnaround time in [Practical 7: CPU Scheduling, FCFS and Non-preemptive Scheduling]. A program that prints the schedule and not the averages has answered half the question.

The two languages, and why

MU names the languages twice and both times indirectly.

Module 1 is C. She sets shared memory, semaphores and message queues, which on a Unix system are the System V interfaces, and she names pthreads where she names a threading library. Her Text Book for this module is Silberschatz, Galvin and Gagne, Operating System Concepts , 10th edition, and every program in it is C. This book therefore writes Module 1 in C on Ubuntu Linux.

Module 2 is Python. Her Text Book for this module is Aho, Ullman and Lam, Data Structures and Algorithms in Python , and one of her two Reference Books is Kanetkar, Data Structures Through Python . This course also teaches Python in Semester 1, so it is the language a second-year student already has. This book therefore writes Module 2 in Python 3.

munotes.in3

How This Practical Is Examined: the Journal, the 80 Per Cent Rule and the Two Questions

MU writes "e.g., pthreads or Java threads" in Module 1, and her other Reference Book is Goodrich's Java edition, so a college may run either module in Java instead. If yours does, the structures and the algorithms in this book are unchanged and the chapters say where the Java form differs. What never changes is the reasoning, and that is what carries the marks.

The shape of every chapter in this book

Each of the twenty exercises is one journal entry, so each is a chapter, and each chapter is laid out the way a journal entry is laid out.

  1. Aim. MU's own words for the exercise.
  2. What you need to know before you start. The idea, in plain English, before any code.
  3. The program, complete, with the reasoning for every part that is not obvious.
  4. The run, which is the output the program actually produced.
  5. Procedure, which is what you type, in order.
  6. Result, which is the one sentence you write in the journal.
  7. Where marks are lost, which is the list of things that go wrong in the hall.
  8. For the journal, which is what goes on the page.
  9. Quick revision and Questions you should be able to answer.

Six of the twenty exercises are split over two chapters, because MU's own bullets under them ask for two different programs. Practical 1 is the shared memory segment and then the race condition; Practical 2 is pipes and then message queues; Practical 10 is disk scheduling and then a file system; Practical 15 is the stack and then the expression conversion; Practical 18 is the AVL tree and then the heap. They are still one journal entry each.

What it does not mean

It does not mean the theory does not matter. It means the theory is examined in the two theory papers this practical pairs with, Principles of Operating Systems and Data Structures. What is examined here is whether you can make the thing run. In practice the students who can make it run are the students who understood it, which is why every chapter explains before it codes.

It does not mean you may skip Module 1 because C is harder. Q.1 is 15 of the 30 marks.

It does not mean the journal can be written in the last week. It is signed as the term goes on, by a teacher who was present. A journal produced complete on the last day is not certified, whatever is written in it.

munotes.in4

How This Practical Is Examined: the Journal, the 80 Per Cent Rule and the Two Questions

Quick revision

  • 2 credits, 60 hours, 50 marks. Type: Practical, so there is no theory paper.
  • Internal 20: work and hands-on assessment 15, journal 5.
  • External 30: two hours, Q.1 on Module 1 for 15, Q.2 on Module 2 for 15. No viva question in this

scheme.

  • A certified journal is compulsory to appear, and at least 80 per cent of the practicals, which

is 16 of the 20, must be completed.

  • Twenty exercises: ten on Principles of Operating Systems in C, ten on Data Structures in Python.
  • Scheme: NEP 2020, MU item 6.14 (N), AC 20 May 2025, in force from 2025-26.

Questions you should be able to answer

1. How many marks does this paper carry and how are they split? Fifty. Twenty internal, which is 15 for the practical work and 5 for the journal, and thirty external, which is a two-hour practical examination of two questions worth 15 each.

2. How many practicals must be completed to be allowed to sit the examination? At least 80 per cent of them, which is 16 of the 20, and the journal must be certified.

3. Is there a viva in the semester end examination for this paper? Not as a question carrying its own marks. MU's printed pattern for this paper is Q.1 and Q.2 only. The first-year practical papers do print a viva question worth 6 marks, which is where the confusion comes from.

4. Why is a 2-credit practical 60 hours when a 2-credit theory paper is 30? Because MU counts one practical credit as 30 hours of laboratory work, against 15 hours of lectures for a theory credit.

5. Which of the two modules is examined first? Q.1 is set on Module 1, which is the operating systems half. Neither module is optional.

Contents This chapter on its own page

munotes.in5

Chapter Two

The Laboratory from Zero: gcc, a Program, and the Manual

Syllabus topic Module 1, "Practical based on Principles of Operating Systems", and her naming of pthreads as the threading library

Aim

To set up the environment for Module 1: install the C compiler on Linux, compile and run a program, read what the compiler says when the program is wrong, and find the manual page for a system call.

What you need to know before you start

Module 1 is written in C and run on Linux, and neither of those is a preference. MU sets shared memory, semaphores and message queues, and she names pthreads as the threading library. Those four things are interfaces of the operating system itself, declared in headers that only a Unix system has, and the book she names as the Text Book for this module, Silberschatz, Galvin and Gagne, writes every one of them in C.

So the machine for Module 1 is a Linux machine. Three ways to get one, in the order most students use them:

HowGood forWatch out for
The college laboratoryThe examination is on these machinesYour files may be wiped at logout; keep a copy
Ubuntu in VirtualBox or VMware on your own laptopLearning at home, and it cannot break WindowsGive it 2 GB of memory or more
Windows Subsystem for Linux, wsl --installFastest to set up on Windows 10 or 11It is a real Linux kernel, and everything in this module works

What does not work: writing these programs on Windows with Turbo C or Dev-C++. There is no sys/shm.h, no sys/sem.h, no sys/msg.h and no fork on Windows, so eight of the ten exercises in Module 1 will not compile at all.

Installing the compiler

Ubuntu ships Python but not a C compiler, so the first command of the term installs one.

sudo apt update
sudo apt install gcc

gcc is the GNU Compiler Collection, and gcc on its own is enough for everything in this module. Many colleges install build-essential instead, which is gcc plus make plus the library headers as one package. Either is fine.

Check that it is there, and note the version, because you will write it in your journal:

gcc --version

The machine this book was checked on answers gcc (Ubuntu 13.3.0-6ubuntu2~24.04.1) 13.3.0. Any version from 9 onwards will compile every program in this book.

Your first program

Open an editor. nano is on every Ubuntu machine and needs nothing learned:

nano hello.c

Type this, exactly.

#include <stdio.h>

int main(void)
{
    printf("hello from the laboratory\n");
    return 0;
}
hello from the laboratory

Save with Ctrl+O then Enter, and leave with Ctrl+X.

Four lines of that program are worth naming, because every listing in Module 1 has the same four parts.

  • #include <stdio.h> brings in the declarations of the input and output functions. Every header
munotes.in6

The Laboratory from Zero: gcc, a Program, and the Manual

in Module 1 is like this: sys/shm.h for shared memory, pthread.h for threads. If you forget one, the compiler warns about an implicit declaration and the program may still build, which is worse than an error because it runs and misbehaves.

  • int main(void) is where the program starts. void says it takes no arguments. Where a program

needs its command line, it is int main(int argc, char *argv[]) instead.

  • printf writes to standard output. \n is a newline, and without it the shell prompt appears

on the same line as your output, which in a journal looks like a mistake.

  • return 0 is the program's exit status, and 0 means success. The operating system keeps it, and

in [Processes: fork, wait, exec, and Why Two Programs Need to Talk] the parent process reads it.

Compiling and running

Two steps, always.

gcc -o hello hello.c
./hello

gcc -o hello hello.c reads hello.c and writes a program called hello. Without -o the compiler writes a file called a.out, which is a habit worth not forming: with four programs in one folder they all become a.out in turn and you run the wrong one.

./hello runs it. The ./ is not decoration. The shell looks for commands in the directories listed in PATH, and the folder you are standing in is not one of them, so hello on its own answers hello: command not found while ./hello says "the one in this directory".

The two flags that belong in every command in this book

gcc -Wall -Wextra -o hello hello.c

-Wall and -Wextra turn on the warnings. They are not on by default, and this is the single most useful habit in the module: a C program that is wrong usually compiles, and the warning is the only notice you get.

For anything with threads there is a third:

gcc -Wall -Wextra -pthread -o prog prog.c

-pthread links the threads library and sets the flags the library needs. Without it, a program using pthread_create fails at the link step with undefined reference to pthread_create, which is not a mistake in your program at all and costs students a lot of time in the hall.

Every program in this book was compiled with gcc -std=c17 -Wall -Wextra -pthread, and no warning was allowed except in the listings below, which exist to show one. A listing that warns by accident teaches a habit that will fail somebody else's compiler.

What the compiler says when you are wrong

This is the part of the chapter to read twice. Three mistakes, and the exact message each one produces, so that you recognise it rather than guess at it.

munotes.in7

The Laboratory from Zero: gcc, a Program, and the Manual

A missing semicolon

#include <stdio.h>

int main(void)
{
    printf("hello")
    return 0;
}
hello.c: In function ‘main’:
hello.c:5:20: error: expected ‘;’ before ‘return’
    5 |     printf("hello")
      |                    ^
      |                    ;
    6 |     return 0;
      |     ~~~~~~

Read the message from the left. hello.c:5:20 is file, line, column. Then error, then what it expected and what it found instead. Then it prints the line, puts a ^ at column 20, and on the next line shows the ; it wanted there.

Two things are worth noticing, and the second one is what saves time in the hall.

The column is exactly right. Column 20 of line 5 is the character after the closing bracket, which is precisely where the semicolon belongs. gcc is not guessing.

It then prints line 6 as well, underlined with ~~~~~~. That is the token it actually tripped over: return. The compiler could not know the statement had ended until it met a word that cannot continue one. So the message names two lines: the one that needs fixing, with a ^, and the one that revealed the problem, with ~. When you see two lines in an error, fix the one with the ^.

A function whose header you forgot

#include <stdio.h>

int main(void)
{
    char name[] = "Mumbai University";
    printf("%zu\n", strlen(name));
    return 0;
}
hello.c: In function ‘main’:
hello.c:6:21: warning: implicit declaration of function ‘strlen’ [-Wimplicit-function-declaration]
    6 |     printf("%zu\n", strlen(name));
      |                     ^~~~~~
hello.c:2:1: note: include ‘<string.h>’ or provide a declaration of ‘strlen’
    1 | #include <stdio.h>
  +++ |+#include <string.h>
    2 |
hello.c:6:21: warning: incompatible implicit declaration of built-in function ‘strlen’ [-Wbuiltin-declaration-mismatch]
    6 |     printf("%zu\n", strlen(name));
      |                     ^~~~~~
hello.c:6:21: note: include ‘<string.h>’ or provide a declaration of ‘strlen’
17

Look at what happened there, because it is the most dangerous thing in this chapter. Two warnings, no error, the program built, and it printed the right answer.

implicit declaration of function means: you called something I have never been told about. strlen is declared in string.h, and this program does not include it. With no declaration, C falls back on an old rule and assumes the function returns int. Here that does no visible harm, because the length of a string fits in an int, so the program is wrong and looks right. That is worse than an error, and it is the whole reason for -Wall.

Notice the third and fourth lines of the message. gcc does not only complain, it writes out the line you are missing:

  +++ |+#include <string.h>

+++ marks a suggested insertion. Add that line and both warnings go.

The same mistake, when the function returns a pointer

#include <stdio.h>

int main(void)
{
    char *copy = strdup("hello");
    printf("%s\n", copy);
    return 0;
}
munotes.in8

The Laboratory from Zero: gcc, a Program, and the Manual

hello.c: In function ‘main’:
hello.c:5:18: warning: implicit declaration of function ‘strdup’ [-Wimplicit-function-declaration]
    5 |     char *copy = strdup("hello");
      |                  ^~~~~~
hello.c:5:18: warning: initialization of ‘char *’ from ‘int’ makes pointer from integer without a cast [-Wint-conversion]

The output block is empty because the program printed nothing at all. It crashed, with a segmentation fault, on every one of the five runs this page was checked over.

This is the same missing header as before, and two things about the message are worth noting before the reason. There is no suggested #include line this time, because gcc offers that hint only for the functions it knows as built-ins, and strdup is not one of them; and the second warning prints no caret line of its own. So the shape of a diagnostic is not fixed, and the part to rely on is the file, line and column at the front of it.

The second warning says why the result is different from the last program. strdup returns a pointer, which on this machine is 8 bytes. With no declaration C assumed int, which is 4 bytes, so the top half of the address was thrown away before it was stored. printf was then handed a number that is not the address of anything, and reading from it killed the program.

A segmentation fault means the program touched memory it does not own. It is the commonest way a C program dies, and in this module it almost always means a pointer that was never valid: a truncated address like this one, an address from shmat that was not checked, or a pointer used after it was freed.

What a truncated address happens to point at is not defined by the language, so on another machine this program might print rubbish instead of crashing. Either way the program is wrong, and either way the fix is #include <string.h>.

The wrong conversion in printf

#include <stdio.h>

int main(void)
{
    double average = 62.5;
    printf("average %d\n", average);
    return 0;
}
hello.c: In function ‘main’:
hello.c:6:22: warning: format ‘%d’ expects argument of type ‘int’, but argument 2 has type ‘double’ [-Wformat=]
    6 |     printf("average %d\n", average);
      |                     ~^     ~~~~~~~
      |                      |     |
      |                      int   double
      |                     %f

No output is printed for this one, and the reason is the lesson. %d tells printf to read an integer, the program handed it a double, and the number that comes out is whatever happened to be lying in the place printf looked. It is different on every run of the same program, so there is nothing a book could honestly print here.

munotes.in9

The Laboratory from Zero: gcc, a Program, and the Manual

The last line of the warning is gcc telling you the answer: %f. It prints the conversion you should have used, underneath the one you did use. The conversions worth remembering:

You are printingWrite
int%d
long%ld
double or float%f, or %.2f for two decimal places
a size_t, from strlen or sizeof%zu
a character%c
a string, meaning a char *%s
a pid_t from getpid%d
an address%p

Comparing a signed number with an unsigned one

The last of the four, and the one that is hardest to see by reading.

#include <stdio.h>

int main(void)
{
    int i = -1;
    unsigned int u = 1;
    if (i < u) printf("i is less than u\n");
    else printf("i is NOT less than u\n");
    return 0;
}
hello.c: In function ‘main’:
hello.c:7:11: warning: comparison of integer expressions of different signedness: ‘int’ and ‘unsigned int’ [-Wsign-compare]
    7 |     if (i < u) printf("i is less than u\n");
      |           ^
i is NOT less than u

Minus one is not less than one. That is what the machine says, and it is not a bug in the machine.

When a signed and an unsigned value of the same size meet in a comparison, C converts the signed one to unsigned, and -1 as a 32-bit unsigned number is 4294967295. So the test asks whether 4294967295 is less than 1, and the answer is no.

This matters in this module because several library calls return unsigned types. A loop written for (i = 0; i < count - 1; i++) where count is unsigned and happens to be 0 does not run zero times; count - 1 is 4294967295 and the loop runs for a very long time. -Wextra is what tells you, and this warning is the reason it is on every command in this book.

And one that only the linker can catch

A function you never wrote at all gets past the compiler with the same implicit-declaration warning, because the compiler works on one file and cannot know what is in the others. The linker is what refuses it, with a message of a different shape: undefined reference to 'total', followed by collect2: error: ld returned 1 exit status.

That is the message to recognise when you forget -pthread. Nothing is wrong with your program; pthread_create exists, but the library holding it was not linked, so the linker says undefined reference to 'pthread_create' and stops. The fix is a flag, not an edit.

Reading the manual

Every system call in Module 1 has a manual page on the machine, and it is the authority. The pages are installed by manpages-dev:

munotes.in10

The Laboratory from Zero: gcc, a Program, and the Manual

sudo apt install manpages-dev
man 2 shmget
man 3 pthread_create

The number is the section: 1 is commands you type, 2 is system calls, 3 is library functions. It matters because some names are in more than one section. man 2 write is the system call; man 1 write is a command for sending a message to another logged-in user.

Every manual page has the same parts, and two of them answer almost every question:

  • SYNOPSIS gives the headers to include and the exact prototype. When you cannot remember

whether shmat takes three arguments, this is faster than any web page.

  • RETURN VALUE gives what it returns on success, what it returns on failure, and the fact that

it sets errno. Every failure check in this book comes from here.

Leave a manual page with q. Search inside one with /shmflg then Enter, and n for the next match.

Checking every call, which is what the marks are for

Almost every line in Module 1 asks the operating system for something, and the operating system is allowed to say no. A program that does not check is a program that crashes with no explanation, and an examiner who sees an unchecked shmget will say so.

The pattern is the same every time.

#include <stdio.h>
#include <errno.h>
#include <string.h>
#include <sys/ipc.h>
#include <sys/shm.h>

int main(void)
{
    /* 0 bytes is not a legal size, so this call is meant to fail. */
    int id = shmget(IPC_PRIVATE, 0, IPC_CREAT | 0600);
    if (id == -1) {
        printf("shmget failed, errno %d, which means: %s\n", errno, strerror(errno));
        return 1;
    }
    printf("this line is never reached\n");
    return 0;
}
shmget failed, errno 22, which means: Invalid argument

Three parts to that, and all three are worth copying into every program you write this term.

  1. The call returns a sentinel on failure, and it is -1 for almost everything in this

module. shmat is the exception: it returns (void *) -1, because it hands back a pointer.

  1. errno says which failure it was. It is set by the call and it is only meaningful when the

call has just failed; reading it after a call that succeeded tells you nothing.

  1. strerror(errno) turns the number into a sentence, and perror("shmget") does the same

thing in one step, writing shmget: Invalid argument to standard error. Use perror in ordinary programs; this book sometimes prints the number as well, so that the page can name it.

The errno values that come up in Module 1:

NumberNameWhen
2ENOENTNo such file or directory: a key with no segment behind it and no IPC_CREAT
13EACCESPermission denied: the segment exists and belongs to somebody else
17EEXISTFile exists: IPC_CREAT with IPC_EXCL, and it was already there
22EINVALInvalid argument: a size of 0, or a size bigger than the machine allows
11EAGAINTry again: a non-blocking receive with nothing to receive
munotes.in11

The Laboratory from Zero: gcc, a Program, and the Manual

Procedure

  1. Log in to a Linux machine, or start your virtual machine.
  2. sudo apt update then sudo apt install gcc manpages-dev.
  3. gcc --version and write the version in your journal.
  4. nano hello.c, type the first program, save and leave.
  5. gcc -Wall -Wextra -o hello hello.c then ./hello.
  6. Remove the semicolon from the printf line, compile again, and read the error. Put it back.
  7. man 2 shmget, read the SYNOPSIS and the RETURN VALUE, and leave with q.

Result

The compiler is installed, a C program has been compiled and run on Linux, the messages the compiler gives for a missing semicolon, a missing header and a wrong conversion have been observed, and the manual page for a system call has been read.

Where marks are lost

  • Forgetting -pthread. Everything from

[Practical 3: Threading and Single Thread Control Flow] onwards needs it, and the failure looks like a bug in your program rather than a missing flag.

  • Running hello instead of ./hello and concluding the program did not build.
  • Fixing the second line an error names. An error prints the line to fix with a ^ and the

line that revealed it with ~. Fix the one with the ^.

  • Ignoring warnings. A %d against a double compiles, runs, and prints rubbish. In the hall

that looks like a wrong algorithm, and you will spend twenty minutes on the wrong thing.

  • Writing the program in Windows and copying it across. Windows line endings leave a \r on

every line, which gcc tolerates but which makes the output look strange. dos2unix file.c fixes it.

  • Not checking a return value. Two marks, every time, in every exercise in this module.

For the journal

Write the aim, the commands you typed in order, the version of gcc, the first program, its output, and one of the compiler errors with the line you changed to produce it. One page.

Quick revision

  • Module 1 is C on Linux because shared memory, semaphores, message queues and pthreads are

interfaces of a Unix operating system. They do not exist on Windows with Turbo C.

  • sudo apt install gcc manpages-dev. Check with gcc --version.
  • gcc -Wall -Wextra -o prog prog.c then ./prog. Add -pthread for anything with threads.
  • A syntax error is reported at the first token that cannot follow, so look at the line above the

one named.

  • implicit declaration of function means a missing #include.
  • man 2 name for a system call, man 3 name for a library function. SYNOPSIS gives the
munotes.in12

The Laboratory from Zero: gcc, a Program, and the Manual

prototype, RETURN VALUE gives the failure check.

  • Every system call is checked: -1 for almost all of them, (void *) -1 for shmat, and

perror or strerror(errno) to say why.

Questions you should be able to answer

1. Why can these programs not be written on Windows with Turbo C? Because sys/shm.h, sys/sem.h, sys/msg.h, pthread.h and fork are interfaces of a Unix operating system. Eight of the ten exercises in Module 1 would not compile.

2. What does -pthread do, and what happens without it? It links the POSIX threads library and sets the flags it needs. Without it a program using pthread_create fails at the link step with undefined reference to pthread_create.

3. The compiler reports an error on line 6 and line 6 looks correct. What has happened? The mistake is almost certainly on line 5. A syntax error is reported at the first token that cannot legally follow, which is usually on the next line.

4. What is the difference between man 2 write and man 3 printf? Section 2 is system calls, which are requests to the kernel. Section 3 is library functions, which are ordinary C functions, though many of them make system calls underneath.

5. What does shmget return on failure, and how do you find out why it failed? It returns -1 and sets errno. perror("shmget") or strerror(errno) turns that into a sentence.

6. Why is a warning about %d against a double worth stopping for? Because the program compiles and runs and prints a meaningless number, so the mistake shows up as a wrong answer rather than as a failure to build.

Contents This chapter on its own page

munotes.in13

Chapter Three

Processes: fork, wait, exec, and Why Two Programs Need to Talk

Syllabus topic Module 1, "Understand shared memory concepts in inter-process communication", which cannot be attempted without a second process

Aim

To understand what a process is, to create one with fork, to wait for it with wait, to replace one with exec, and to see why two processes cannot simply share a variable.

What you need to know before you start

A program is a file on disk. A process is a program that is running: the instructions, the data, the stack, the open files and the place in the code it has reached. The same program run twice is two processes.

Every process has a number, its process identifier or pid, which the operating system gives out and which is unique among the processes running at that moment. Every process also has a parent, the process that created it, and the parent's number is its ppid.

#include <stdio.h>
#include <unistd.h>

int main(void)
{
    printf("I am process %d\n", getpid());
    printf("my parent is  %d\n", getppid());
    return 0;
}
I am process 6
my parent is  1

Those two numbers will be different on your machine, and different again the next time you run it. Inside the container this book is checked in, the first process started is number 1, so the numbers are small; on your own Ubuntu they will be in the thousands.

getpid and getppid are in unistd.h, which is the header for most of the Unix system calls. It will be in every program in this module.

Why a second process cannot just share a variable

This is the point of the whole exercise, and it is worth proving before anything is built.

Each process gets its own address space: its own memory, laid out as if it owned the whole machine. Two processes may both use the address 0x7f00, and the operating system arranges for those to be two different pieces of physical memory. That is what makes a crashing program unable to damage another one.

It is also what makes inter-process communication necessary. A variable in one process is invisible to another, however the two are related, and the next listing shows it.

fork: one process becomes two

#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>

int main(void)
{
    int counter = 0;

    pid_t pid = fork();

    if (pid == 0) {
        counter = counter + 100;
        printf("child : counter is %d, my pid is %d\n", counter, getpid());
    } else {
        sleep(1);
        counter = counter + 1;
        printf("parent: counter is %d, my pid is %d\n", counter, getpid());
    }
    return 0;
}
child : counter is 100, my pid is 7
parent: counter is 1, my pid is 6

fork is the only way a Unix process is created, and it does something no other function does: it returns twice. One process goes in and two come out, both of them sitting at the line after the fork, both with the same code, the same open files and the same values in their variables. They are told apart by what fork returned to each of them:

munotes.in14

Processes: fork, wait, exec, and Why Two Programs Need to Talk

fork returnsTo whomMeaning
0the childyou are the new process
a positive numberthe parentthis is your child's pid
-1the callerthe fork failed and there is no child

So if (pid == 0) is not a test of a value, it is the question "am I the child?".

Now look at counter. It was 0 before the fork. The child added 100 and printed 100. The parent added 1 and printed 1. Neither saw the other's change, because after the fork there are two variables called counter, one in each address space. The child's 100 went nowhere near the parent's copy.

That is the problem MU's first exercise solves. Shared memory is a piece of memory the operating system maps into both address spaces at once, so that a change made by one is seen by the other.

The sleep(1) in the parent is there to make the output order fixed for the page. Without it the two lines appear in whichever order the scheduler chooses, which is the subject of [Practical 3: Threading and Single Thread Control Flow].

wait: the parent waits for the child to finish

A parent that finishes first leaves the child running with no parent, and a child that finishes first leaves a record nobody has collected. Both have names, and both are avoided by the same call.

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>

int main(void)
{
    pid_t pid = fork();
    if (pid == -1) {
        perror("fork");
        return 1;
    }

    if (pid == 0) {
        printf("child : working\n");
        return 7;
    }

    int status;
    pid_t done = wait(&status);

    printf("parent: child %d finished\n", done);
    if (WIFEXITED(status))
        printf("parent: it returned %d\n", WEXITSTATUS(status));
    return 0;
}
child : working
parent: child 7 finished
parent: it returned 7

wait(&status) stops the parent until one of its children finishes, and returns that child's pid. The status it fills in is not the exit code; it is a word with several pieces of information packed into it, and two macros take them out:

  • WIFEXITED(status) is true if the child ended normally, by returning from main or calling

exit.

  • WEXITSTATUS(status) is then the value it returned, and only the bottom 8 bits of it, so a

child that returns 300 is reported as 44.

Two macros for the other case, which matters in this module because a program that dies of a segmentation fault is a program that was killed:

munotes.in15

Processes: fork, wait, exec, and Why Two Programs Need to Talk

  • WIFSIGNALED(status) is true if the child was killed by a signal.
  • WTERMSIG(status) is then the signal number: 11 is SIGSEGV, the segmentation fault.

In the run above, the child's pid and the value it returned are both 7 by coincidence. That is the container's low process numbers at work, and on your machine the pid will be a large number while the returned value stays 7.

The orphan and the zombie

Two words an examiner asks for, and they are the two halves of getting wait wrong.

An orphan is a child whose parent finished first. It is not an error and nothing is lost: the operating system gives the orphan a new parent, process number 1, which collects its exit status in due course. On a modern Linux desktop that is systemd.

A zombie is the opposite and it is a real leak. A child that has finished still has one thing left, its exit status, and the operating system must keep it until the parent asks. Until then the child is in state Z, holding a slot in the process table, using no memory and no processor. A parent that forks a thousand children and never waits leaves a thousand zombies, and when the table is full no new process can start on the machine at all.

A zombie is cleared the moment the parent calls wait, and by the parent finishing, which makes the zombie an orphan and hands it to process 1. The rule: every fork is matched by a wait.

exec: a process replaces its own program

fork makes a copy of the program you are already running. To run a different program you need the other half of the pair.

#include <stdio.h>
#include <unistd.h>
#include <sys/wait.h>

int main(void)
{
    pid_t pid = fork();

    if (pid == 0) {
        printf("child : about to become another program\n");
        fflush(stdout);
        execl("/bin/echo", "echo", "hello from echo", (char *) NULL);
        perror("execl");
        return 1;
    }

    wait(NULL);
    printf("parent: the child is done\n");
    return 0;
}
child : about to become another program
hello from echo
parent: the child is done

execl throws away the program the process is running and loads a new one in its place. The pid does not change, the open files do not change, but the code, the data and the stack are all replaced. exec does not return on success, because there is nothing left to return to; that is why the perror after it needs no if, and why reaching it at all means the call failed.

Two details in that listing which are not decoration.

fflush(stdout) before the exec. Output is buffered, so printf puts the line in a buffer that is written out when the program ends. The exec discards the whole program including that buffer, so without the flush the child's own line is lost. A student who meets this thinks printf did not run.

munotes.in16

Processes: fork, wait, exec, and Why Two Programs Need to Talk

The (char *) NULL at the end. execl takes the arguments one after another and has no way to know how many there are, so the list ends with a null pointer. Leaving it out, or writing plain 0, is a real bug that sometimes works by luck.

The first argument is the program to run. The second is what the program will see as its own name, which is why "echo" appears twice in effect. Nearly every program on a Unix machine is started by exactly this pair of calls: the shell forks, and the child execs the command you typed.

The five calls this module is built on

CallHeaderWhat it doesOn failure
fork()unistd.h, and sys/types.h for pid_tone process becomes tworeturns -1
wait(&st)sys/wait.hwait for any child, collect its statusreturns -1
waitpid(p, &st, 0)sys/wait.hwait for one named childreturns -1
execl(path, ...)unistd.hreplace this process's programreturns -1
_exit(n)unistd.hend at once, without flushing buffersnever returns

Several children, and waiting for all of them

Most of MU's exercises have more than one child, so this pattern is worth having ready. It also carries the two traps that catch everybody the first time.

#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>
#include <sys/wait.h>

int main(void)
{
    for (int i = 1; i <= 3; i++) {
        pid_t pid = fork();
        if (pid == -1) {
            perror("fork");
            return 1;
        }
        if (pid == 0) {
            printf("child %d reporting\n", i);
            fflush(stdout);      /* or the line can be lost: see below */
            _exit(0);            /* the child must NOT go round the loop */
        }
    }

    while (wait(NULL) > 0)
        ;                        /* collect every child */

    printf("all three children are done\n");
    return 0;
}
child 1 reporting
child 2 reporting
child 3 reporting
all three children are done

The order of those first three lines is not fixed. Run it eight times and this book's check saw five different orders. Nothing decides which child reaches the screen first: all three are ready to run and the operating system picks. The last line is always last, because wait holds the parent until every child has finished, and that is the only ordering the program guarantees.

This is why the page prints the lines as a set rather than as a sequence: what is proved over every run is that the three children each reported once and the parent reported after them. A book that printed one fixed order would be showing a student something their own machine will contradict.

munotes.in17

Processes: fork, wait, exec, and Why Two Programs Need to Talk

Trap one: a child that does not leave the loop forks again

Remove the _exit(0) and child 1 goes round to i = 2 and forks a child of its own, which goes round to i = 3. Three passes of the loop produce seven processes instead of three, and the output is a different mess every time. Every child in a forking loop ends with _exit or return , on its own line, and it is the first thing to look for when a program prints too much.

_exit rather than exit, because exit flushes the buffers the child inherited from its parent. Anything the parent had written but not yet flushed would then be written twice, once by the child and once by the parent.

Trap two: _exit throws away what the child has printed

This is the same program with the fflush taken out, and nothing else changed.

#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>
#include <sys/wait.h>

int main(void)
{
    for (int i = 1; i <= 3; i++) {
        pid_t pid = fork();
        if (pid == -1) {
            perror("fork");
            return 1;
        }
        if (pid == 0) {
            printf("child %d reporting\n", i);
            _exit(0);
        }
    }

    while (wait(NULL) > 0)
        ;

    printf("all three children are done\n");
    return 0;
}
all three children are done

All three children have gone. They ran, they called printf, and not one of their lines arrived.

The reason is how C buffers output, and it depends on where the output is going.

Output is going toHow it is bufferedWhen it is written
a terminalline by lineat every newline
a file, a pipe, or another programin a block, usually 4096 byteswhen the block is full, or on flush, or at exit

printf does not write to the screen. It writes into a buffer, and the buffer is emptied later. On a terminal that happens at each newline, so a student running this in the laboratory sees all four lines and believes the program is correct. Send the same program's output to a file, which is what this book's checker does and what ./prog > out.txt does, and the child's line is still sitting in the buffer when _exit ends the process without emptying it.

Three ways to be safe, and the first is the one to use:

  1. fflush(stdout); immediately before _exit, as in the correct version above.
  2. fflush(stdout); before the fork, which empties whatever the parent had pending so that no

child inherits it. Needed as well, in any program that prints before forking.

munotes.in18

Processes: fork, wait, exec, and Why Two Programs Need to Talk

  1. setbuf(stdout, NULL); at the top of main, which turns buffering off altogether. Useful

while you are debugging a program whose output arrives in the wrong order, and not worth shipping.

This is why the execl listing earlier in the chapter has an fflush in it, and it is the same rule: exec and _exit both discard the buffer, so anything you want kept has to be out of it first.

The four ways two processes can talk

Everything in Module 1 is one of these, and MU sets the first two as exercises.

WayWhat it isMU's exercise
Shared memorya piece of memory mapped into both address spacesPractical 1
Message passingthe kernel holds the data and hands it overPractical 2
A fileboth processes open the same filenot set
Signalsone number, no datanot set

The first two are the ones that matter, and the difference between them is the subject of [Practical 2 continued: Message Queues, Blocking and Non-blocking]. In one sentence: shared memory is fast and you must do the synchronising yourself; message passing is slower and the kernel does the synchronising for you.

Procedure

  1. Write and run the program that prints getpid and getppid. Run it three times and note that

the pid changes.

  1. Write the fork program with the counter. Confirm that the child's 100 and the parent's 1 do

not affect each other.

  1. Add wait and print the child's exit status.
  2. Write the execl program. Remove the fflush and run it again to see the child's own line

disappear.

  1. Write the three-child loop. Run it five times and note that the order of the child lines

changes.

  1. Remove the _exit(0) and count how many lines appear.
  2. Put the _exit(0) back, remove the fflush, and run it twice: once as ./prog and once as

./prog > out.txt followed by cat out.txt. The child lines are in the first and gone from the second.

Result

Two processes were created with fork, told apart by its return value, and shown to have separate copies of the same variable. The parent collected each child's exit status with wait. A child replaced its own program with execl. The need for shared memory or message passing follows from the separate address spaces.

Where marks are lost

  • Not checking fork for -1. One mark, and it is the easiest one in the paper.
  • A child that does not _exit, so a forking loop breeds processes.
  • Never calling wait, and then being unable to answer what a zombie is.
  • Expecting the parent's variable to change because the child changed it. This is the
munotes.in19

Processes: fork, wait, exec, and Why Two Programs Need to Talk

misunderstanding the whole of Practical 1 exists to correct.

  • Assuming the parent runs first, or the child does. Nothing guarantees either. Where the

order matters, wait is what makes it certain.

  • Forgetting fflush before an exec or an _exit, and losing output that was written. It

works in the laboratory, where output goes to a terminal, and fails the moment anybody redirects it to a file.

  • Writing pid_t without #include <sys/types.h>. With -std=c17 that is an error, not a

warning: unknown type name pid_t.

For the journal

Write the aim, the fork program with the counter, its output with your own pid numbers, and one sentence saying why the two counters differ. Add the wait version and the exit status it printed. Note the definitions of orphan and zombie.

Quick revision

  • A program is a file; a process is a running program, with a pid, a ppid and an address space of

its own.

  • fork returns twice: 0 to the child, the child's pid to the parent, -1 on failure.
  • The child gets a copy of the parent's memory, so a variable changed in one is unchanged in

the other. That is why inter-process communication is needed.

  • wait(&status) blocks until a child finishes and returns its pid. WIFEXITED and WEXITSTATUS

take the exit code out; WIFSIGNALED and WTERMSIG say which signal killed it.

  • Orphan: the parent died first, and process 1 adopts it. Zombie: the child died and nobody has

called wait, so its entry stays in the process table.

  • exec replaces the program in the current process and does not return on success. fork plus

exec is how every command you type is run.

  • Every child in a loop ends with _exit, and every fork is matched by a wait.
  • Output to a terminal is flushed at each newline; output to a file or a pipe is flushed in

blocks. _exit and exec both discard the buffer, so fflush(stdout) comes first.

  • The order in which several children print is not fixed. wait is the only thing that orders

anything.

Questions you should be able to answer

1. What does fork return, and to whom? Zero to the child, the child's pid to the parent, and -1 to the caller if no child could be created.

2. A parent sets x = 5, forks, and the child sets x = 9. What does the parent print? Five. The child has its own copy in its own address space.

3. What is a zombie process, and how is it cleared? A child that has finished but whose exit status has not been collected, so its entry stays in the process table. The parent calling wait clears it; so does the parent finishing, which hands the child to process 1.

munotes.in20

Processes: fork, wait, exec, and Why Two Programs Need to Talk

4. What is the difference between an orphan and a zombie? An orphan has lost its parent and is still running or has been adopted by process 1; a zombie has finished and is waiting for a parent that has not called wait. The orphan is harmless, the zombie holds a slot in the process table.

5. Why does exec not return? Because it replaces the program that would have been returned to. If the line after it runs, the call failed.

6. Why must a forked child not fall through a forking loop? Because it would fork children of its own. Three passes of the loop would create seven processes instead of three.

7. A forking program prints correctly in the laboratory and loses lines when its output is redirected to a file. Why? Because output to a terminal is flushed at every newline and output to a file is flushed in blocks. A child that ends with _exit discards its buffer, so its line never reaches the file. fflush(stdout) before the _exit fixes it.

8. Name the two ways of passing data between processes that MU sets, and the one-line difference between them. Shared memory, which is fast and leaves the synchronising to you, and message passing, which is slower and where the kernel does the synchronising.

Contents This chapter on its own page

munotes.in21

Chapter Four

Practical 1: Process Communication using Shared Memory

Syllabus topic Module 1, "Process Communication using Shared Memory: Understand shared memory concepts in inter-process communication."

Aim

To understand shared memory in inter-process communication: to create a shared memory segment, attach it to two processes, pass data through it, and remove it afterwards.

What you need to know before you start

Two processes cannot see each other's variables. That was proved in [Processes: fork, wait, exec, and Why Two Programs Need to Talk]: after a fork there are two copies of every variable, and a change to one is invisible in the other.

Shared memory is the operating system's answer. You ask the kernel for a block of memory that does not belong to any process, and then each process asks for that block to appear somewhere in its own address space. After that, both are looking at the same bytes. A write by one is visible to the other immediately, with no copying and no system call in between.

That last sentence is the whole reason shared memory exists, and also the whole reason it is dangerous:

  • It is the fastest form of inter-process communication, because after the setup there is

no kernel involvement at all. Writing to shared memory is exactly as fast as writing to an ordinary variable.

  • The kernel does nothing to keep order. It does not stop one process reading while another

writes, and it does not tell the reader when new data has arrived. Both of those are your job, and both are the subject of [Practical 1 continued: the Race Condition, Semaphores, and Producer and Consumer].

The four calls

System V shared memory is four calls, and they are always used in this order.

CallIn wordsHeader
shmgetask for a segment, get an id backsys/ipc.h, sys/shm.h
shmatattach: make it appear in my address spacesys/shm.h
shmdtdetach: I am finished with itsys/shm.h
shmctlask about it, or destroy itsys/shm.h

Every one of them is checked, because every one of them can fail.

The key, and why a segment needs a name

shmget takes a key, which is a number that identifies the segment on the whole machine. It is how a second, unrelated program finds the same segment: the writer and the reader agree on a key in advance, and both pass it to shmget.

There are two ways to supply one.

  • IPC_PRIVATE means "give me a brand new segment and do not put a name on it". Nobody can

look it up. This is right when the only other process is your own child, because the child inherits the id through the fork.

  • A number you choose, written in hexadecimal by convention, such as 0x4d55. Any

program that knows the number can ask for the same segment. This is right for two separate programs.

munotes.in22

Practical 1: Process Communication using Shared Memory

There is a third way, ftok, and it is further down the chapter.

Permissions, which are file permissions

The last argument to shmget carries two things joined with |:

  • IPC_CREAT, meaning create the segment if it does not exist. Without it, shmget only

looks up an existing one and fails with ENOENT if there is none.

  • Nine permission bits in octal, exactly as for a file: 0666 is read and write for

everybody, 0600 is read and write for the owner only. A segment created 0600 cannot be attached by another user, which in a college laboratory where everyone shares a machine is usually what you want.

IPC_EXCL may be added to IPC_CREAT, and then the call fails with EEXIST if the segment already exists rather than handing you the old one. It is how a program refuses to reuse somebody else's leftovers.

The program: a parent and a child sharing one segment

This is the version to put in the journal if the question says "using fork ".

#include <stdio.h>
#include <string.h>
#include <sys/types.h>
#include <unistd.h>
#include <sys/wait.h>
#include <sys/ipc.h>
#include <sys/shm.h>

#define SIZE 128

int main(void)
{
    /* 1. ask the kernel for a segment of SIZE bytes */
    int shmid = shmget(IPC_PRIVATE, SIZE, IPC_CREAT | 0600);
    if (shmid == -1) {
        perror("shmget");
        return 1;
    }
    printf("parent: segment id %d, %d bytes\n", shmid, SIZE);
    fflush(stdout);           /* BEFORE the fork: see the note below */

    pid_t pid = fork();
    if (pid == -1) {
        perror("fork");
        shmctl(shmid, IPC_RMID, NULL);
        return 1;
    }

    if (pid == 0) {
        /* 2. the child attaches and writes */
        char *shared = shmat(shmid, NULL, 0);
        if (shared == (void *) -1) {
            perror("child shmat");
            _exit(1);
        }
        strcpy(shared, "Mumbai University, Semester 3");
        printf("child : wrote  [%s]\n", shared);
        fflush(stdout);
        shmdt(shared);            /* 3. detach */
        _exit(0);
    }

    wait(NULL);                   /* the write must have happened first */

    /* the parent attaches to the same segment and reads */
    char *shared = shmat(shmid, NULL, 0);
    if (shared == (void *) -1) {
        perror("parent shmat");
        shmctl(shmid, IPC_RMID, NULL);
        return 1;
    }
    printf("parent: read   [%s]\n", shared);
    shmdt(shared);

    /* 4. destroy it, or it outlives the program */
    if (shmctl(shmid, IPC_RMID, NULL) == -1)
        perror("shmctl");
    else
        printf("parent: segment removed\n");
    return 0;
}
parent: segment id 0, 128 bytes
child : wrote  [Mumbai University, Semester 3]
parent: read   [Mumbai University, Semester 3]
parent: segment removed

The segment id on your machine will not be 0. Inside the container this book is checked in the machine has just started, so the kernel hands out the first identifier it has; on a college machine it will be a larger number and it changes every run.

munotes.in23

Practical 1: Process Communication using Shared Memory

Reading the program line by line

shmget(IPC_PRIVATE, SIZE, IPC_CREAT | 0600) asks for 128 bytes. It returns a small non-negative integer, the shared memory identifier. That number is not an address and it is not a key: it is a handle, and it is the thing the other three calls take.

The child inherits shmid through the fork, because it is an ordinary variable that was set before the fork. That is why IPC_PRIVATE is enough here and no key is needed.

shmat(shmid, NULL, 0) attaches. It returns a pointer, and from then on the segment is ordinary memory: strcpy into it, read it, put a struct in it. The NULL says "put it wherever you like", which is the right answer; asking for a particular address is a way to make a program fail on a machine where that address is busy. The 0 is the flags, and SHM_RDONLY there gives a read-only attachment.

shmat fails with (void *) -1, not NULL. It is the one call in this module whose failure value is not plain -1, and if (shared == NULL) is a check that never fires.

fflush(stdout) before the fork. This line is not decoration, and leaving it out is the mistake this page made first. The parent's printf puts its line in a buffer. The child inherits a copy of that buffer, and when the child flushes its own message it writes out the parent's line as well, so parent: segment id 0, 128 bytes appears twice, once from the child and once from the parent. Flushing before the fork means there is nothing in the buffer to be inherited. The rule was stated in [Processes: fork, wait, exec, and Why Two Programs Need to Talk] and this is what it looks like in a real program.

wait(NULL) before the parent reads. Without it the parent might read the segment before the child has written anything, and print an empty string. Nothing in shared memory tells a reader that the data has arrived; here the wait is what makes the order certain, and in a program where the two run at the same time a semaphore does that job.

shmdt(shared) detaches. The segment still exists; only this process's view of it has gone. The pointer is invalid afterwards and using it is a segmentation fault.

shmctl(shmid, IPC_RMID, NULL) destroys the segment. IPC_RMID means remove. This is the line students forget, and the next section is about what happens then.

A segment outlives the program that made it

This is the single most important fact about System V shared memory, and it surprises everybody.

A segment is not owned by a process. It is owned by the kernel, and it stays on the machine until something removes it or the machine is restarted. A program that creates a segment and exits without IPC_RMID leaves it there, attached to nothing, using memory, for ever.

munotes.in24

Practical 1: Process Communication using Shared Memory

You can see it. ipcs -m lists every shared memory segment on the machine, and the next program creates one and then asks ipcs to print it.

#include <stdio.h>
#include <stdlib.h>
#include <sys/ipc.h>
#include <sys/shm.h>

int main(void)
{
    int shmid = shmget(0x4d55, 1024, IPC_CREAT | 0666);
    if (shmid == -1) {
        perror("shmget");
        return 1;
    }
    printf("created segment id %d with key 0x4d55\n", shmid);
    fflush(stdout);

    system("ipcs -m");

    shmctl(shmid, IPC_RMID, NULL);
    return 0;
}
created segment id 0 with key 0x4d55

------ Shared Memory Segments --------
key        shmid      owner      perms      bytes      nattch     status
0x00004d55 0          student    666        1024       0

Read the columns, because an examiner may ask you to.

ColumnWhat it is
keythe number the program asked for, in hexadecimal. 0x00000000 means IPC_PRIVATE
shmidthe identifier shmget returned
ownerthe user who created it
permsthe permission bits, in octal
bytesthe size
nattchhow many processes have it attached right now
statusdest if it has been marked for destruction but is still attached

A segment with nattch of 0 that nobody is going to attach again is a leak. Two commands clear one up by hand:

ipcs -m
ipcrm -m 32768

ipcrm -m takes the shmid, not the key. There is also ipcrm -M 0x4d55, with a capital M, which takes the key.

In a college laboratory this matters more than it looks. The machine has a limit on how many segments may exist at once, and a class of forty students each leaving a few behind will reach it. When shmget starts failing with ENOSPC for no apparent reason, somebody's program forgot IPC_RMID.

Two separate programs, which is what the examiner usually asks for

The fork version works because the child inherits the identifier. Two unrelated programs cannot inherit anything, so they agree on a key instead. Here is the pair, and this is the form to have ready for the examination.

The writer, which creates the segment and puts data in it:

#include <stdio.h>
#include <string.h>
#include <sys/ipc.h>
#include <sys/shm.h>

#define KEY  0x4d55
#define SIZE 256

int main(void)
{
    int shmid = shmget(KEY, SIZE, IPC_CREAT | 0666);
    if (shmid == -1) {
        perror("writer shmget");
        return 1;
    }

    char *shared = shmat(shmid, NULL, 0);
    if (shared == (void *) -1) {
        perror("writer shmat");
        return 1;
    }

    strcpy(shared, "notes for Computer Science Practical 3");
    printf("writer: wrote to the segment with key 0x%x\n", KEY);

    shmdt(shared);
    return 0;
}
munotes.in25

Practical 1: Process Communication using Shared Memory

The reader, which finds the same segment and takes the data out. Note the third argument to shmget: no IPC_CREAT, because the reader must not create anything. If the segment is not there, the reader should fail and say so, not make an empty one and read nothing.

#include <stdio.h>
#include <sys/ipc.h>
#include <sys/shm.h>

#define KEY  0x4d55
#define SIZE 256

int main(void)
{
    int shmid = shmget(KEY, SIZE, 0666);
    if (shmid == -1) {
        perror("reader shmget");
        return 1;
    }

    char *shared = shmat(shmid, NULL, 0);
    if (shared == (void *) -1) {
        perror("reader shmat");
        return 1;
    }

    printf("reader: found  [%s]\n", shared);

    shmdt(shared);

    /* the reader is the last one out, so the reader cleans up */
    if (shmctl(shmid, IPC_RMID, NULL) == -1)
        perror("reader shmctl");
    else
        printf("reader: segment removed\n");
    return 0;
}
writer: wrote to the segment with key 0x4d55
reader: found  [notes for Computer Science Practical 3]
reader: segment removed

Compile and run them as two programs, in two commands:

gcc -Wall -Wextra -o writer writer.c
gcc -Wall -Wextra -o reader reader.c
./writer
./reader

The output above is exactly that session: the writer's line, then the reader's two lines. In the laboratory you can also run them in two terminals, and running ./reader first shows what a missing segment looks like: reader shmget: No such file or directory, which is ENOENT.

ftok, a key made from a file

Choosing a number by hand risks collision: two students on one machine both picking 0x1234 get each other's data. ftok makes a key from a file that already exists, so the file name is the agreement instead of the number.

#include <stdio.h>
#include <errno.h>
#include <string.h>
#include <sys/ipc.h>

int main(void)
{
    key_t a = ftok("/tmp", 'M');
    key_t b = ftok("/tmp", 'M');
    key_t c = ftok("/tmp", 'N');

    if (a == -1 || b == -1 || c == -1) {
        perror("ftok");
        return 1;
    }

    printf("the same path and letter twice: %s\n", a == b ? "same key" : "DIFFERENT");
    printf("the same path, letter N     : %s\n", a == c ? "same key" : "different key");
    printf("the top byte of the key is  : 0x%02x, which is the letter %c\n",
           (unsigned) (a >> 24) & 0xff, (char) ((a >> 24) & 0xff));

    key_t missing = ftok("/tmp/there-is-no-such-file", 'M');
    printf("a path that does not exist  : returns %d, errno says %s\n",
           (int) missing, strerror(errno));
    return 0;
}
the same path and letter twice: same key
the same path, letter N     : different key
the top byte of the key is  : 0x4d, which is the letter M
a path that does not exist  : returns -1, errno says No such file or directory

That program proves the three things worth knowing, and deliberately does not print the key itself. ftok takes a path that must exist and a single character, the project identifier, and mixes them into a key:

munotes.in26

Practical 1: Process Communication using Shared Memory

  • The same path and the same letter always give the same key on the same machine, which is the

whole point: both programs call it and both get the same number.

  • A different letter gives a different key, so one file can name several segments.
  • The path must exist. ftok on a missing file returns -1 and sets ENOENT.

Two warnings, and the second is why the listings above use a plain number instead:

  • The key is built from the file's inode number, so it is a different value on every machine.

The top byte is the letter you passed, which is why the top byte above is 0x4d for 'M'; the rest comes from the file. While this page was being checked the key changed between two runs, because the container gets a fresh /tmp each time and therefore a fresh inode number. That is exactly the failure mode described next.

  • Delete the file and make it again and the inode changes, so the key changes. A program that

is still running is then looking at a segment nobody else can find any more. A key written into the source as a number cannot do that.

Putting a structure in the segment, not just a string

Shared memory is bytes, so anything can go in it. A structure is the usual choice, because real data has parts.

#include <stdio.h>
#include <string.h>
#include <sys/types.h>
#include <unistd.h>
#include <sys/wait.h>
#include <sys/ipc.h>
#include <sys/shm.h>

struct student {
    int  roll;
    char name[40];
    int  marks[3];
};

int main(void)
{
    int shmid = shmget(IPC_PRIVATE, sizeof(struct student), IPC_CREAT | 0600);
    if (shmid == -1) {
        perror("shmget");
        return 1;
    }

    if (fork() == 0) {
        struct student *s = shmat(shmid, NULL, 0);
        if (s == (void *) -1) {
            perror("child shmat");
            _exit(1);
        }
        s->roll = 2331;
        strcpy(s->name, "Aarti Kulkarni");
        s->marks[0] = 78;
        s->marks[1] = 65;
        s->marks[2] = 92;
        printf("child : filled in the record\n");
        fflush(stdout);
        shmdt(s);
        _exit(0);
    }

    wait(NULL);

    struct student *s = shmat(shmid, NULL, 0);
    if (s == (void *) -1) {
        perror("parent shmat");
        shmctl(shmid, IPC_RMID, NULL);
        return 1;
    }
    int total = s->marks[0] + s->marks[1] + s->marks[2];
    printf("parent: roll %d, %s\n", s->roll, s->name);
    printf("parent: marks %d %d %d, total %d\n",
           s->marks[0], s->marks[1], s->marks[2], total);
    shmdt(s);
    shmctl(shmid, IPC_RMID, NULL);
    return 0;
}
child : filled in the record
parent: roll 2331, Aarti Kulkarni
parent: marks 78 65 92, total 235

sizeof(struct student) is the size to ask for, never a number typed by hand. And the pointer shmat returns is used as a struct student with no cast needed, because in C a void converts to any object pointer on its own.

munotes.in27

Practical 1: Process Communication using Shared Memory

A pointer may never be stored in shared memory. The segment appears at a different address in each process, so an address that is valid in the writer means nothing in the reader. A structure holding a char * is a bug; one holding a char name[40], as above, is correct. This is the trap that catches good students, because the program compiles and runs and only the answers are wrong.

Shared memory against the alternatives

Shared memoryMessage passingA file
Speedfastest, no kernel after setupa copy in and a copy outslowest, goes to disk
Synchronisingentirely your jobthe kernel blocks and wakesyour job, with locks
Notificationnone, the reader must poll or wait on a semaphorethe receiver simply blocksnone
Amount of dataas much as you ask formessage by messageunlimited
Survives the programyes, until IPC_RMIDyes, until removedyes

The middle row is why MU sets both Practical 1 and Practical 2, and the full comparison is in [Practical 2 continued: Message Queues, Blocking and Non-blocking].

Procedure

  1. nano shm_fork.c, type the first program, and compile it with

gcc -Wall -Wextra -o shm_fork shm_fork.c.

  1. Run it. Note the segment id, and run it twice more to see the id change.
  2. Run ipcs -m and confirm the segment is gone.
  3. Comment out the shmctl line, compile, run, and then ipcs -m. The segment is there with

nattch 0. Remove it with ipcrm -m and the id it printed.

  1. Write writer.c and reader.c. Compile both. Run ./reader first and read the error,

then ./writer and ./reader in that order.

  1. Write the structure version and confirm the parent reads back all three marks.

Result

A shared memory segment was created, attached to two processes, written by one and read by the other, and removed. The identifier was seen to change on every run. A segment left behind was found with ipcs -m and removed with ipcrm. Two unrelated programs communicated through one segment by agreeing on a key.

Where marks are lost

  • Checking shmat against NULL. It fails with (void *) -1. This is the commonest

single error in this exercise.

  • Forgetting shmctl(shmid, IPC_RMID, NULL). The segment is left on the machine, and the

examiner can see it with ipcs -m after your program has ended.

  • Putting IPC_CREAT in the reader. The reader then silently makes an empty segment when

the writer has not run, prints nothing, and looks as if the writing failed.

  • Storing a pointer in the segment. The addresses differ in the two processes.
  • No wait before reading, so the parent reads before the child has written.
  • Using the key where the id is wanted, or the other way round. shmget takes the key and
munotes.in28

Practical 1: Process Communication using Shared Memory

returns the id; shmat, shmdt and shmctl take the id.

  • Not checking shmget. In a laboratory where segments have been leaked all term, this is

the call that fails.

For the journal

Write the aim, MU's own wording, the first program in full, its output with your own segment id, and the ipcs -m listing before and after ipcrm. Then the writer and reader pair with the two compile commands and the session. One sentence of conclusion: shared memory let two processes exchange data without the kernel copying it, and the segment had to be removed by hand.

Quick revision

  • Two processes have separate address spaces, so a variable in one is invisible in the other.

Shared memory is a block the kernel maps into both.

  • Four calls: shmget to get an id, shmat to attach, shmdt to detach, shmctl with

IPC_RMID to destroy.

  • shmget(key, size, IPC_CREAT | 0666). IPC_PRIVATE for a parent and child; a chosen number

or ftok for unrelated programs. IPC_EXCL refuses an existing segment.

  • shmat returns a pointer and fails with (void *) -1. Every other call fails with -1.
  • A segment belongs to the kernel, not to the process. It survives the program. ipcs -m lists

them and ipcrm -m <shmid> removes one.

  • Shared memory is the fastest form of inter-process communication because after the setup the

kernel is not involved, and for the same reason it provides no synchronisation at all.

  • Never store a pointer in shared memory. The segment sits at a different address in each

process.

Questions you should be able to answer

1. What does shmget return, and is it the key? It returns a shared memory identifier, which is a handle used by shmat, shmdt and shmctl. The key is what you pass in; the id is what you get back, and they are different numbers.

2. Why is shared memory the fastest form of inter-process communication? Because after shmat there is no system call and no copying. Both processes are reading and writing the same physical memory as if it were an ordinary variable.

3. What is the price of that speed? No synchronisation. The kernel will not stop two processes writing at once and will not tell a reader that data has arrived, so a semaphore or some other arrangement is needed.

4. What happens if a program exits without calling shmctl with IPC_RMID? The segment stays on the machine until something removes it or the machine restarts. It shows in ipcs -m with nattch 0, and enough of them will make shmget fail for everybody.

munotes.in29

Practical 1: Process Communication using Shared Memory

5. How does shmat report failure, and why does it catch people? It returns (void *) -1, not NULL, because NULL would be a legitimate address to be given. A check written against NULL never fires.

6. Why must the reader not pass IPC_CREAT? Because then a reader run before the writer creates an empty segment of its own and reads nothing, instead of reporting that the segment does not exist.

7. Two unrelated programs need to share a segment. How do they find the same one? They agree on a key: either a number written into both programs, or one computed by both with ftok from the same path and the same project character.

8. Why can a char * not be stored in shared memory? Because shmat may map the segment at a different address in each process, so the numeric address stored by one process does not point at the same thing in the other. Store an array instead.

Contents This chapter on its own page

munotes.in30

Chapter Five

Practical 1 continued: the Race Condition, Semaphores, and Producer and Consumer

Syllabus topic Module 1, "Implement producer-consumer synchronization using shared memory and semaphores. Explore issues of race conditions and how to avoid them."

Aim

To see a race condition happen on a shared counter, to remove it with a semaphore, and to implement the producer-consumer problem with shared memory and semaphores.

What you need to know before you start

[Practical 1: Process Communication using Shared Memory] ended with a warning: the kernel gives you shared memory and does nothing at all to keep order in it. This chapter is what that costs and how it is paid.

The critical section

A critical section is a piece of code that touches shared data and must not be interleaved with another process doing the same. It is not the data that is critical, it is the code.

The smallest possible example is adding one to a shared counter. In C it is one line:

*counter = *counter + 1;

On the machine it is three steps, and that is where the trouble is:

  1. Read the value out of shared memory into a register.
  2. Add one to the register.
  3. Write the register back to shared memory.

Now let two processes, A and B, each run those three steps on a counter holding 10, and let the operating system switch between them at the worst moment:

StepProcess AProcess BThe counter holds
1reads 1010
2reads 1010
3adds one, has 1110
4adds one, has 1110
5writes 1111
6writes 1111

Two increments happened and the counter went up by one. One update was lost.

A race condition is exactly this: the answer depends on the order in which the processes happen to be scheduled, and that order is not yours to decide. The program is not wrong in a way you can see by reading it. It is wrong sometimes.

The race condition, seen

This program starts two children and makes each of them add one to a shared counter two thousand times. The right answer is four thousand.

The sched_yield() in the middle is there on purpose. It asks the operating system to give some other process a turn, which widens the gap between the read and the write and makes the race happen often enough to watch. Without it the loop is so fast that each child may finish before the other starts, and on the machine this page was checked on the program then printed the right answer six times out of six, which is the most misleading result a student can get.

#include <stdio.h>
#include <sched.h>
#include <sys/types.h>
#include <unistd.h>
#include <sys/wait.h>
#include <sys/ipc.h>
#include <sys/shm.h>

#define N 2000

int main(void)
{
    int shmid = shmget(IPC_PRIVATE, sizeof(long), IPC_CREAT | 0600);
    if (shmid == -1) {
        perror("shmget");
        return 1;
    }
    long *counter = shmat(shmid, NULL, 0);
    if (counter == (void *) -1) {
        perror("shmat");
        return 1;
    }
    *counter = 0;

    for (int p = 0; p < 2; p++) {
        if (fork() == 0) {
            long *c = shmat(shmid, NULL, 0);
            if (c == (void *) -1)
                _exit(1);
            for (int i = 0; i < N; i++) {
                long v = *c;          /* read  */
                sched_yield();        /* the window the race lives in */
                *c = v + 1;           /* write */
            }
            shmdt(c);
            _exit(0);
        }
    }

    while (wait(NULL) > 0)
        ;

    printf("expected %d, got %ld\n", 2 * N, *counter);
    shmdt(counter);
    shmctl(shmid, IPC_RMID, NULL);
    return 0;
}
munotes.in31

Practical 1 continued: the Race Condition, Semaphores, and Producer and Consumer

expected 4000, got 2000

That number is different on your machine and different on the next run. On the machine this page was checked on it was 4000 twice and 2000 four times in six runs. It can be anything from 2000 to 4000: 2000 is what happens when the two children lose almost every update to each other, and 4000 is what happens when they happen not to overlap at all.

A book cannot print one number for that, so it prints a run and says so. What it can print is the next program's answer.

The race condition, measured

The same experiment, twenty times over, inside one program, with a verdict at the end.

#include <stdio.h>
#include <sched.h>
#include <sys/types.h>
#include <unistd.h>
#include <sys/wait.h>
#include <sys/ipc.h>
#include <sys/shm.h>

#define N      2000
#define TRIALS 20

int main(void)
{
    int shmid = shmget(IPC_PRIVATE, sizeof(long), IPC_CREAT | 0600);
    if (shmid == -1) {
        perror("shmget");
        return 1;
    }
    long *counter = shmat(shmid, NULL, 0);
    if (counter == (void *) -1) {
        perror("shmat");
        return 1;
    }

    int right = 0;
    long lowest = 2L * N;

    for (int t = 0; t < TRIALS; t++) {
        *counter = 0;
        for (int p = 0; p < 2; p++) {
            if (fork() == 0) {
                long *c = shmat(shmid, NULL, 0);
                for (int i = 0; i < N; i++) {
                    long v = *c;
                    sched_yield();
                    *c = v + 1;
                }
                shmdt(c);
                _exit(0);
            }
        }
        while (wait(NULL) > 0)
            ;
        if (*counter == 2L * N)
            right++;
        if (*counter < lowest)
            lowest = *counter;
    }

    printf("trials run                      : %d\n", TRIALS);
    printf("the right answer each time      : %d\n", 2 * N);
    printf("trials that gave it             : %d\n", right);
    printf("the lowest total seen           : %ld\n", lowest);
    printf("updates were lost at least once : %s\n",
           right < TRIALS ? "yes" : "no");

    shmdt(counter);
    shmctl(shmid, IPC_RMID, NULL);
    return 0;
}
trials run                      : 20
the right answer each time      : 4000
trials that gave it             : 0
the lowest total seen           : 2000
updates were lost at least once : yes
munotes.in32

Practical 1 continued: the Race Condition, Semaphores, and Producer and Consumer

Zero out of twenty. The last line is the one that matters, and it is the same on every run: an unprotected shared counter does not merely risk a wrong answer, it gives one.

This is also the honest way to run any experiment about timing, and it is worth remembering for [Practical 3: Threading and Single Thread Control Flow], where the same trap appears in the form of a single measurement of speed.

The semaphore

A semaphore is a counter held by the kernel, with two operations on it that the kernel performs atomically, meaning that nothing can happen in the middle of one.

OperationAlso calledWhat it does
waitP, down, acquireif the value is greater than 0, take one away and carry on; otherwise block until somebody makes it greater than 0
signalV, up, releaseadd one, and wake a process that was blocked

P and V are Dijkstra's names for them, from the Dutch proberen and verhogen, and an examiner may use either pair.

A semaphore whose value is only ever 0 or 1 is a binary semaphore or a mutex, short for mutual exclusion. Set it to 1 and the pattern for a critical section is:

wait(mutex);          /* nobody else may be inside now */
   ... critical section ...
signal(mutex);        /* let the next one in */

The first process to arrive takes the value from 1 to 0 and goes in. The second finds 0 and blocks. When the first signals, the value goes back to 1 and the second is woken and takes it to 0 in its turn. Only one is ever inside.

A semaphore with a larger value is a counting semaphore, and it is used to count a resource: a value of 3 means three processes may hold it at once. Practical 5 and Practical 6 both use one.

The three System V calls

CallIn words
semget(key, n, flags)ask for a SET of n semaphores, get an id back
semop(id, ops, count)perform wait or signal operations, atomically
semctl(id, n, cmd, arg)set the starting value, read it, or destroy the set

The thing to notice is that System V gives you a set of semaphores, not one. semget(key, 2, ...) makes two, numbered 0 and 1, and every operation names which member of the set it means. That is awkward for a single mutex and exactly right for the producer-consumer, which needs two.

union semun, which you have to declare yourself

semctl takes a fourth argument of type union semun, and the C library does not define it. The manual page says so, and every program using System V semaphores begins with this:

munotes.in33

Practical 1 continued: the Race Condition, Semaphores, and Producer and Consumer

union semun {
    int              val;
    struct semid_ds *buf;
    unsigned short  *array;
};

Leaving it out gives unknown type name 'union semun', and it is the single commonest reason a student's semaphore program does not compile.

SEM_UNDO, which saves the laboratory

A sembuf carries a flag, and SEM_UNDO is worth understanding rather than copying.

With SEM_UNDO set, the kernel remembers what this process did to the semaphore and undoes it if the process dies. Without it, a process that takes a mutex and then crashes leaves the value at 0 for ever, and every other process blocks on it until somebody removes the set with ipcrm. In a laboratory that is a machine nobody can use.

So: SEM_UNDO on a mutex, always. It is left off the producer-consumer's two semaphores further down, on purpose, because there an undo would be wrong: a producer that dies holding an item should not silently give the slot back as though the item existed.

The counter, corrected

The same program as before, with three lines added and one pair of calls around the critical section. Nothing else has changed, including the sched_yield that made the race happen.

#include <stdio.h>
#include <sched.h>
#include <sys/types.h>
#include <unistd.h>
#include <sys/wait.h>
#include <sys/ipc.h>
#include <sys/shm.h>
#include <sys/sem.h>

#define N 2000

/* the C library does not define this: every program declares it */
union semun {
    int              val;
    struct semid_ds *buf;
    unsigned short  *array;
};

static void sem_wait_(int semid)
{
    struct sembuf op = { 0, -1, SEM_UNDO };
    if (semop(semid, &op, 1) == -1)
        perror("semop wait");
}

static void sem_signal(int semid)
{
    struct sembuf op = { 0, 1, SEM_UNDO };
    if (semop(semid, &op, 1) == -1)
        perror("semop signal");
}

int main(void)
{
    int shmid = shmget(IPC_PRIVATE, sizeof(long), IPC_CREAT | 0600);
    if (shmid == -1) {
        perror("shmget");
        return 1;
    }

    int semid = semget(IPC_PRIVATE, 1, IPC_CREAT | 0600);
    if (semid == -1) {
        perror("semget");
        shmctl(shmid, IPC_RMID, NULL);
        return 1;
    }

    union semun arg;
    arg.val = 1;                          /* a mutex starts at 1 */
    if (semctl(semid, 0, SETVAL, arg) == -1) {
        perror("semctl SETVAL");
        return 1;
    }

    long *counter = shmat(shmid, NULL, 0);
    if (counter == (void *) -1) {
        perror("shmat");
        return 1;
    }
    *counter = 0;

    for (int p = 0; p < 2; p++) {
        if (fork() == 0) {
            long *c = shmat(shmid, NULL, 0);
            if (c == (void *) -1)
                _exit(1);
            for (int i = 0; i < N; i++) {
                sem_wait_(semid);         /* enter the critical section */
                long v = *c;
                sched_yield();
                *c = v + 1;
                sem_signal(semid);        /* leave it */
            }
            shmdt(c);
            _exit(0);
        }
    }

    while (wait(NULL) > 0)
        ;

    printf("expected %d, got %ld\n", 2 * N, *counter);

    shmdt(counter);
    shmctl(shmid, IPC_RMID, NULL);
    semctl(semid, 0, IPC_RMID);           /* the set must be removed too */
    return 0;
}
munotes.in34

Practical 1 continued: the Race Condition, Semaphores, and Producer and Consumer

expected 4000, got 4000

Four thousand, every time. This listing is not marked as varying, which means the checker required that exact line on all ten runs it made of it, five under each char convention, and got it. The broken version could not make that claim and this one can.

The function is called sem_wait_ with a trailing underscore because sem_wait is already a real function, the POSIX one, declared in semaphore.h. Naming your own function sem_wait compiles here and collides the moment a program includes that header, which is the sort of bug that appears only when a program grows.

Notice also the last line: semctl(semid, 0, IPC_RMID) destroys the semaphore set, exactly as shmctl with IPC_RMID destroys the segment. A semaphore set is a System V object like any other: it survives the program, ipcs -s lists them, and ipcrm -s <id> removes one by hand.

Producer and consumer, with shared memory and semaphores

This is MU's own wording for the exercise, and it is the classic problem of the module.

A producer makes items and puts them somewhere. A consumer takes them out and uses them. The place in between holds a limited number, here exactly one, and two rules must hold at all times:

  • the producer must not write into a slot that is still full;
  • the consumer must not read from a slot that is empty.

Neither rule is about mutual exclusion, and that is why a mutex alone cannot solve this. What is needed is a way for each side to wait for the other, and that is what counting semaphores do.

Two of them, and their names are their meanings:

SemaphoreStarts atCountsWaited on bySignalled by
empty1free slotsthe producer, before writingthe consumer, after reading
full0filled slotsthe consumer, before readingthe producer, after writing

Read the table as a sentence. The producer waits for a free slot and then creates a full one; the consumer waits for a full slot and then creates a free one. The two semaphores hand the single slot back and forth.

#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>
#include <sys/wait.h>
#include <sys/ipc.h>
#include <sys/shm.h>
#include <sys/sem.h>

#define ITEMS 5
#define EMPTY 0                  /* member 0 of the semaphore set */
#define FULL  1                  /* member 1 */

union semun {
    int              val;
    struct semid_ds *buf;
    unsigned short  *array;
};

/* P and V on one member of the set */
static void P(int semid, int member)
{
    struct sembuf op = { (unsigned short) member, -1, 0 };
    if (semop(semid, &op, 1) == -1)
        perror("semop P");
}

static void V(int semid, int member)
{
    struct sembuf op = { (unsigned short) member, 1, 0 };
    if (semop(semid, &op, 1) == -1)
        perror("semop V");
}

int main(void)
{
    int shmid = shmget(IPC_PRIVATE, sizeof(int), IPC_CREAT | 0600);
    if (shmid == -1) {
        perror("shmget");
        return 1;
    }
    int semid = semget(IPC_PRIVATE, 2, IPC_CREAT | 0600);
    if (semid == -1) {
        perror("semget");
        shmctl(shmid, IPC_RMID, NULL);
        return 1;
    }

    union semun arg;
    arg.val = 1;
    semctl(semid, EMPTY, SETVAL, arg);    /* one free slot */
    arg.val = 0;
    semctl(semid, FULL, SETVAL, arg);     /* no filled slot */

    int *slot = shmat(shmid, NULL, 0);
    if (slot == (void *) -1) {
        perror("shmat");
        return 1;
    }

    if (fork() == 0) {                    /* the producer */
        int *s = shmat(shmid, NULL, 0);
        for (int i = 1; i <= ITEMS; i++) {
            P(semid, EMPTY);              /* wait for a free slot */
            *s = i * i;
            printf("producer: put %d in the slot\n", *s);
            fflush(stdout);
            V(semid, FULL);               /* the slot is now full */
        }
        shmdt(s);
        _exit(0);
    }

    if (fork() == 0) {                    /* the consumer */
        int *s = shmat(shmid, NULL, 0);
        for (int i = 1; i <= ITEMS; i++) {
            P(semid, FULL);               /* wait for a full slot */
            printf("consumer: took %d out of the slot\n", *s);
            fflush(stdout);
            V(semid, EMPTY);              /* the slot is free again */
        }
        shmdt(s);
        _exit(0);
    }

    while (wait(NULL) > 0)
        ;
    printf("both finished\n");

    shmdt(slot);
    shmctl(shmid, IPC_RMID, NULL);
    semctl(semid, 0, IPC_RMID);
    return 0;
}
munotes.in35

Practical 1 continued: the Race Condition, Semaphores, and Producer and Consumer

producer: put 1 in the slot
consumer: took 1 out of the slot
producer: put 4 in the slot
consumer: took 4 out of the slot
producer: put 9 in the slot
consumer: took 9 out of the slot
producer: put 16 in the slot
consumer: took 16 out of the slot
producer: put 25 in the slot
consumer: took 25 out of the slot
both finished

Strict alternation, and not a single item lost or read twice, on every one of the ten runs. That is not luck and it is not the scheduler being kind: with one slot, the producer cannot possibly get ahead, because after one item the empty semaphore is 0 and its next P blocks until the consumer has taken that item out.

Two things follow, and both are worth saying because they are the bridge to Practical 5.

A one-slot buffer makes the two processes run in lock-step, which is correct but slow: each side spends most of its time waiting. Give the buffer several slots and they can work at the same time, and that is [Practical 5: Process Synchronisation and the Bounded Buffer].

No mutex was needed here. With one producer and one consumer and one slot, the two semaphores already make it impossible for both to touch the slot at once. Add a second producer and that stops being true, and a third semaphore appears.

munotes.in36

Practical 1 continued: the Race Condition, Semaphores, and Producer and Consumer

The order of the waits, which is where deadlock comes from

If the buffer had several slots and a mutex, the producer's code would be:

P(empty);             /* first wait for a slot  */
P(mutex);             /* then take the lock     */
   ... put the item in ...
V(mutex);
V(full);

Swap those first two lines and the program deadlocks. The producer takes the mutex and then blocks waiting for a free slot; the consumer cannot get the mutex to free one; neither ever moves. Wait for the resource first, take the lock second, and release in the opposite order. That single rule prevents most of the deadlocks in this module, and Practical 5 shows it happening.

Procedure

  1. Write the race program. Run it ten times and write down every answer you get.
  2. Remove the sched_yield() line, compile, and run it ten more times. Note how often it now

gives the right answer, and that the program is no less wrong for it.

  1. Write the twenty-trial version and record the verdict.
  2. Add the semaphore. Run ten times and confirm 4000 every time.
  3. Run ipcs -s after a run to confirm the semaphore set was removed.
  4. Write the producer-consumer program. Confirm the lines alternate strictly.
  5. Swap P(empty) and P(mutex) in the form above in a buffer of three slots and see the program

hang. Press Ctrl+C, and then ipcrm whatever it left behind.

Result

An unprotected shared counter lost updates in twenty of twenty trials. A binary semaphore round the critical section made the same program give the exact answer on every run. The producer-consumer problem was solved with one shared slot and two counting semaphores, and the two processes alternated strictly with no item lost or duplicated.

Where marks are lost

  • Not declaring union semun. The program does not compile, and the message names a type the

student has never heard of.

  • Forgetting semctl(semid, 0, SETVAL, arg). A new semaphore set starts at 0, not 1, so a

mutex that is never initialised blocks the first process that waits on it and the program hangs for ever.

  • Using one semaphore for the producer-consumer. It needs two, and an examiner asks why.
  • Removing the shared memory and forgetting the semaphore set. ipcs -s shows it. Both

IPC_RMID calls are needed.

  • Putting the P(mutex) before the P(empty), which deadlocks.
  • Leaving SEM_UNDO off a mutex, so a crash leaves the machine locked.
  • Claiming the race condition from one run. If the program happens to print 4000, a student
munotes.in37

Practical 1 continued: the Race Condition, Semaphores, and Producer and Consumer

who says "it works" has not understood the exercise. Run it ten times.

For the journal

Write the aim, MU's own wording, the race program, and the ten answers you got, as a list. Then the semaphore version and its answer. Then the producer-consumer program and its output. The conclusion is two sentences: an unsynchronised critical section loses updates because the read and the write can be separated, and a semaphore makes the section atomic so that the answer is the same every time.

Quick revision

  • A critical section is code that touches shared data. c = c + 1 is three machine steps, and a

switch between the read and the write loses an update.

  • A race condition is a program whose answer depends on the scheduling order. It is wrong

sometimes, which is worse than wrong always.

  • A semaphore is a kernel counter with two atomic operations: wait, also called P or down, and

signal, also called V or up. A binary semaphore, value 0 or 1, is a mutex.

  • semget for a SET of semaphores, semop for the operations, semctl for SETVAL, GETVAL and

IPC_RMID. union semun is not in the library and must be declared.

  • A new set starts at 0. A mutex must be set to 1 before use.
  • SEM_UNDO makes the kernel undo this process's operations if it dies, and belongs on a mutex.
  • Producer and consumer need two counting semaphores: empty, started at the number of slots, and

full, started at 0. The producer waits on empty and signals full; the consumer does the opposite.

  • Wait for the resource before taking the lock, and release in the opposite order, or the program

deadlocks.

Questions you should be able to answer

1. What is a race condition? A situation where two or more processes access shared data at the same time and the result depends on the order in which they happen to be scheduled, so the program gives different answers on different runs.

2. Why does counter = counter + 1 need protecting when it is one line of C? Because it is three machine operations: read, add, write. A process switched out between the read and the write is working from a value that is already stale, and its write loses the other process's update.

3. What is a semaphore, and what are its two operations called? A counter held by the kernel with two atomic operations: wait, also called P or down, which decrements and blocks at 0; and signal, also called V or up, which increments and wakes a waiting process.

4. What is the difference between a binary semaphore and a counting semaphore? A binary semaphore takes only 0 and 1 and is used for mutual exclusion. A counting semaphore takes any non-negative value and counts instances of a resource.

munotes.in38

Practical 1 continued: the Race Condition, Semaphores, and Producer and Consumer

5. Why does the producer-consumer problem need two semaphores? Because the two sides must wait for different things. The producer waits for a free slot and the consumer waits for a filled one, and one counter cannot represent both.

6. What value does a semaphore have when it is created, and why does that matter? Zero. A mutex left at zero blocks the first process that waits on it, so the program hangs, and semctl with SETVAL must be called first.

7. What does SEM_UNDO do? It makes the kernel record this process's semaphore operations and reverse them if the process dies, so a crash while holding a mutex does not leave the semaphore locked for everybody else.

8. Why must the wait on empty come before the wait on the mutex? Because a producer holding the mutex while waiting for a slot cannot be helped: the consumer needs the mutex to free a slot. Taking the lock last, and releasing in the opposite order, avoids that deadlock.

9. A student runs the unsynchronised counter once, gets the right answer, and says the program is correct. What is wrong with that? A race condition does not fail every time. The answer depends on the scheduling, so one correct run proves nothing; twenty runs on the machine this page was checked on gave the right answer zero times.

Contents This chapter on its own page

munotes.in39

Chapter Six

Practical 2: Process Communication with Pipes

Syllabus topic Module 1, "Process Communication using Message Passing: Use message queues/pipes to solve the producer-consumer problem."

Aim

To pass data between processes by message passing rather than shared memory, using an anonymous pipe and a named pipe, and to solve the producer-consumer problem over a pipe.

What you need to know before you start

Shared memory, in [Practical 1: Process Communication using Shared Memory], gave two processes one piece of memory and left the order entirely to them. Message passing is the other way of doing it: the data is handed to the kernel by one process and taken from the kernel by the other, and the kernel does the waiting.

That difference is the whole of Practical 2, and it has one large consequence:

With message passing you get synchronisation for free. A reader that asks for data before there

is any simply waits, and it is the kernel that puts it to sleep and wakes it up. No semaphore is

needed.

The price is that the data is copied: once into the kernel and once out again. Shared memory copies nothing. That is why shared memory is faster and why it is harder to get right.

A pipe

A pipe is a one-way channel between two processes, held in the kernel, with a fixed amount of room in it. One end is written and the other is read. It is the oldest form of inter-process communication on Unix and it is what the shell's | is made of: ls | wc -l is two processes and one pipe.

Two kinds, and MU's exercise can be answered with either:

Anonymous pipeNamed pipe, also called a FIFO
Made bypipe()mkfifo() or the mkfifo command
Has a namenoyes, it is a file in a directory
Who can use itthis process and its childrenany process that can open the file
Lives untilthe last end is closedthe file is deleted

The five properties of a pipe that matter in the examination

  1. It is one way. Data written at one end comes out at the other and not back. Two processes

that must both talk and listen need two pipes.

  1. It is a byte stream. The pipe does not remember where one write ended and the next began.

This page proves it below.

  1. A read with nothing in the pipe blocks, which is the free synchronisation.
  2. A read when the pipe is empty and every writer has closed it returns 0, which is end of

file. That is how a reader knows to stop.

  1. A write to a pipe with no reader left kills the writer with the signal SIGPIPE, or, if

that signal is ignored, fails with EPIPE.

munotes.in40

Practical 2: Process Communication with Pipes

The program: a parent and a child, one pipe

#include <stdio.h>
#include <string.h>
#include <sys/types.h>
#include <unistd.h>
#include <sys/wait.h>

int main(void)
{
    int fd[2];

    if (pipe(fd) == -1) {
        perror("pipe");
        return 1;
    }

    pid_t pid = fork();
    if (pid == -1) {
        perror("fork");
        return 1;
    }

    if (pid == 0) {                       /* the child writes */
        close(fd[0]);                     /* it never reads: close that end */
        const char *msg = "hello down the pipe";
        write(fd[1], msg, strlen(msg) + 1);
        close(fd[1]);
        _exit(0);
    }

    close(fd[1]);                         /* the parent never writes */
    char buf[64];
    ssize_t n = read(fd[0], buf, sizeof buf);
    printf("parent read %zd bytes: [%s]\n", n, buf);
    close(fd[0]);
    wait(NULL);
    return 0;
}
parent read 20 bytes: [hello down the pipe]

Reading the program

pipe(fd) makes the pipe and fills the array with two file descriptors. The convention is fixed and worth memorising: fd[0] is the read end, fd[1] is the write end. Zero for reading because standard input is 0; one for writing because standard output is 1.

fork after pipe, never before. The child inherits the two descriptors because they were open when it was created. A pipe made after the fork exists only in the process that made it.

Each side closes the end it does not use, and this is not tidiness. The parent has fd[1] open, so as far as the kernel is concerned there is still a writer, and when the child closes its copy the pipe does not reach end of file. A parent that forgets close(fd[1]) and reads in a loop waits for ever for data from itself. This single line is the commonest cause of a pipe program that hangs.

write(fd[1], msg, strlen(msg) + 1) writes the string and the + 1 writes the terminating zero byte as well, which is why the parent can print buf as a string and why read returned 20 for a 19-character message. A pipe carries bytes and knows nothing about strings.

read(fd[0], buf, sizeof buf) returns how many bytes it actually got, which may be fewer than you asked for. It returns -1 on error and 0 at end of file.

A pipe is a byte stream, and this is the surprise

The next program has the child write three lines with three separate calls to write, and the parent read in a loop until end of file.

#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>
#include <sys/wait.h>

int main(void)
{
    int fd[2];
    if (pipe(fd) == -1) {
        perror("pipe");
        return 1;
    }

    if (fork() == 0) {
        close(fd[0]);
        for (int i = 1; i <= 3; i++) {
            char line[32];
            int len = snprintf(line, sizeof line, "item %d\n", i * 10);
            write(fd[1], line, (size_t) len);       /* three writes */
        }
        close(fd[1]);
        _exit(0);
    }

    close(fd[1]);
    char buf[128];
    ssize_t n;
    while ((n = read(fd[0], buf, sizeof buf)) > 0)  /* how many reads? */
        printf("consumer got %zd bytes:\n%.*s", n, (int) n, buf);

    printf("read returned %zd, which is end of file\n", n);
    close(fd[0]);
    wait(NULL);
    return 0;
}
munotes.in41

Practical 2: Process Communication with Pipes

consumer got 24 bytes:
item 10
item 20
item 30
read returned 0, which is end of file

One read, not three. Three writes of 8 bytes each arrived as a single read of 24, on all ten runs. The pipe put the bytes in order and threw away every boundary between them.

That is what "byte stream" means, and it has three consequences a student should be able to state:

  • A reader cannot tell how many writes there were. It gets bytes, and it must decide where one

message ends. Here the newline does that job; in a real program it is a length written in front of the data, or a fixed-size record.

  • A read may also return less than one write. The opposite case happens with large writes: ask

for 128 bytes and you may get 40, because that is all that had arrived. This is why the read is in a while loop, and why a single read outside one is a bug waiting for a slow writer.

  • This is exactly what a message queue fixes. A message queue keeps each message whole, and

that is the subject of [Practical 2 continued: Message Queues, Blocking and Non-blocking].

%.*s in that printf prints exactly n characters of buf and no more. Printing buf as a plain %s would read past the bytes that arrived, because nothing in the pipe wrote a terminating zero this time.

Producer and consumer over a pipe, which is MU's own wording

Compare this with the semaphore version in [Practical 1 continued: the Race Condition, Semaphores, and Producer and Consumer]. It does the same job and there is no semaphore in it, because the pipe blocks for you.

#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>
#include <sys/wait.h>

#define ITEMS 5

int main(void)
{
    int fd[2];
    if (pipe(fd) == -1) {
        perror("pipe");
        return 1;
    }

    if (fork() == 0) {                          /* the producer */
        close(fd[0]);
        for (int i = 1; i <= ITEMS; i++) {
            int item = i * i;
            if (write(fd[1], &item, sizeof item) != (ssize_t) sizeof item) {
                perror("producer write");
                _exit(1);
            }
            printf("producer: produced %d\n", item);
            fflush(stdout);
        }
        close(fd[1]);                           /* tells the consumer to stop */
        _exit(0);
    }

    close(fd[1]);                               /* the consumer */
    int item;
    ssize_t n;
    int total = 0;
    while ((n = read(fd[0], &item, sizeof item)) == (ssize_t) sizeof item) {
        printf("consumer: consumed %d\n", item);
        total = total + item;
    }
    if (n == -1)
        perror("consumer read");
    printf("consumer: the pipe is closed, the total is %d\n", total);

    close(fd[0]);
    wait(NULL);
    return 0;
}
munotes.in42

Practical 2: Process Communication with Pipes

producer: produced 1
producer: produced 4
producer: produced 9
producer: produced 16
producer: produced 25
consumer: consumed 1
consumer: consumed 4
consumer: consumed 9
consumer: consumed 16
consumer: consumed 25
consumer: the pipe is closed, the total is 55

Three things to take from that output.

The producer got all five items in before the consumer took the first one out. That is not a fault: the pipe has room, in Linux 64 kibibytes by default, and 20 bytes fits easily. The producer only waits when the pipe is full, and the consumer only waits when it is empty. Compare the one-slot semaphore version, where the two alternated strictly because the buffer held exactly one item. A pipe is a bounded buffer that the kernel provides ready-made, and its bound is the pipe's capacity.

An int was sent as four raw bytes, with write(fd[1], &item, sizeof item), and read back the same way. That works because both processes are the same program on the same machine, so the two agree about how an int is laid out. It would not be safe between machines, and it is the reason network programs convert to a defined byte order first.

close(fd[1]) in the producer is what ends the consumer's loop. Without it the consumer's read would block for ever after the fifth item, waiting for a writer that has finished but not said so. The total of 55 at the end is 1 plus 4 plus 9 plus 16 plus 25, and it is there so that the program proves nothing was lost.

A named pipe, which two unrelated programs can use

An anonymous pipe reaches only your own children. A named pipe, or FIFO, is a file in a directory, and any program that can open that file can use it. It is the pipe equivalent of giving a shared memory segment a key.

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <sys/wait.h>

#define PATH "/tmp/munotes_fifo"

int main(void)
{
    unlink(PATH);                          /* in case one is left over */

    if (mkfifo(PATH, 0600) == -1) {
        perror("mkfifo");
        return 1;
    }
    printf("made the named pipe %s\n", PATH);
    fflush(stdout);
    system("ls -l /tmp/munotes_fifo | cut -c1-11");

    if (fork() == 0) {
        int fd = open(PATH, O_WRONLY);     /* blocks until a reader arrives */
        if (fd == -1) {
            perror("child open");
            _exit(1);
        }
        const char *m = "sent through a named pipe";
        write(fd, m, strlen(m) + 1);
        close(fd);
        _exit(0);
    }

    int fd = open(PATH, O_RDONLY);         /* blocks until a writer arrives */
    if (fd == -1) {
        perror("parent open");
        return 1;
    }
    char buf[64];
    ssize_t n = read(fd, buf, sizeof buf);
    printf("parent read %zd bytes: [%s]\n", n, buf);
    close(fd);

    wait(NULL);
    unlink(PATH);                          /* a FIFO must be deleted */
    return 0;
}
munotes.in43

Practical 2: Process Communication with Pipes

made the named pipe /tmp/munotes_fifo
prw-------
parent read 26 bytes: [sent through a named pipe]

The second line of that output is the evidence that a FIFO is a file of its own kind. ls -l prints a letter for the type in the first column, and here it is p, for pipe. An ordinary file prints -, a directory d, a symbolic link l.

Two behaviours of a FIFO are worth knowing and are the reason this program forks:

  • open for reading blocks until a writer opens it, and open for writing blocks until a reader does.

That is how the two sides find each other, and it means the writer cannot simply run first and finish. In the laboratory you run the writer in one terminal and the reader in another and watch the first one wait.

  • A FIFO is a file and has to be deleted, with unlink in a program or rm at the shell.

Like a shared memory segment, it outlives the program that made it; unlike a segment, it shows up in ls.

You can try the same thing with no C at all, which is worth doing once because it makes the idea concrete: mkfifo /tmp/f, then cat < /tmp/f in one terminal and echo hello > /tmp/f in another.

Writing to a pipe nobody is reading

The last of the five properties, and the one that produces a mysterious death.

#include <stdio.h>
#include <string.h>
#include <errno.h>
#include <signal.h>
#include <unistd.h>

int main(void)
{
    signal(SIGPIPE, SIG_IGN);              /* so we live to print the error */

    int fd[2];
    if (pipe(fd) == -1) {
        perror("pipe");
        return 1;
    }

    close(fd[0]);                          /* there is now no reader at all */

    ssize_t n = write(fd[1], "x", 1);
    printf("write returned %zd, errno says %s\n", n, strerror(errno));
    return 0;
}
write returned -1, errno says Broken pipe

Without the signal(SIGPIPE, SIG_IGN) line that program would not print anything: the kernel sends SIGPIPE to a process that writes to a pipe with no reader, and the default action for SIGPIPE is to kill it. The program simply disappears, with no message, and a student looks for a bug in the loop above.

That is also why ls | head -1 does not print an error when head stops reading: ls is killed by SIGPIPE, which is the intended design.

Pipes against shared memory

PipeShared memory
Who does the waitingthe kernel, automaticallyyou, with semaphores
Data is copiedtwice, in and outnever
Directionone way per pipeboth ways
Keeps message boundariesno, it is a byte streamthere are no messages
Related processes onlyanonymous yes, named nono, a key is enough
Cleaning upclosing the descriptors, and unlink for a FIFOshmctl with IPC_RMID
Blocks whenfull on write, empty on readnever
munotes.in44

Practical 2: Process Communication with Pipes

The full comparison, with message queues in it as well, is in [Practical 2 continued: Message Queues, Blocking and Non-blocking].

Procedure

  1. Write the first program. Compile with gcc -Wall -Wextra -o pipe1 pipe1.c and run it.
  2. Remove the parent's close(fd[1]), put the parent's read in a while loop, and run it again.

The program hangs. Press Ctrl+C and put the line back.

  1. Write the three-write program and count the reads. Note that the boundaries are gone.
  2. Write the producer-consumer program and confirm the total.
  3. Write the FIFO program. Run ls -l /tmp/munotes_fifo yourself while it is running and note the

p.

  1. At the shell: mkfifo /tmp/f, then cat < /tmp/f in one terminal and echo hello > /tmp/f in

another. Then rm /tmp/f.

Result

Two processes exchanged data through an anonymous pipe with no semaphore, because a pipe blocks on an empty read and on a full write. Three separate writes were shown to arrive as one read, which establishes that a pipe is a byte stream with no message boundaries. The producer-consumer problem was solved over a pipe and the total proved nothing was lost. A named pipe was created, seen in ls -l as type p, used between two processes and deleted.

Where marks are lost

  • Not closing the unused end. The reader keeps a write end open, end of file never arrives,

and the program hangs. This is the single commonest fault in this exercise.

  • pipe after fork. The two processes then have two different pipes.
  • Mixing up fd[0] and fd[1]. Zero reads, one writes.
  • Reading once instead of in a loop, and assuming one read returns one write.
  • Printing the buffer with %s when nothing wrote a terminating zero. Use %.*s and the

count that read returned.

  • Forgetting to unlink the FIFO, so the next run fails with EEXIST from mkfifo.
  • Not knowing why a program writing to a closed pipe dies silently. It is SIGPIPE.

For the journal

Write the aim, MU's own wording, the first program and its output, and then the producer-consumer program with its output and the total. Add the three-write experiment and one sentence saying how many reads it took and why. If your college asks for the named pipe as well, add the mkfifo program and the ls -l line showing the p. The conclusion: a pipe gives message passing with the synchronisation done by the kernel, at the cost of copying the data and of losing the boundaries between writes.

munotes.in45

Practical 2: Process Communication with Pipes

Quick revision

  • A pipe is a one-way channel in the kernel. pipe(fd) gives fd[0] to read and fd[1] to

write. Make it before the fork.

  • Each process closes the end it does not use, or end of file never arrives.
  • read returns the number of bytes it got, 0 at end of file, -1 on error.
  • A pipe is a byte stream: three writes may arrive as one read, and one write may arrive as

several reads. The reader has to find the message boundaries itself.

  • A read on an empty pipe blocks and a write to a full pipe blocks, so a pipe is a bounded buffer

the kernel provides. No semaphore is needed.

  • Writing to a pipe with no reader raises SIGPIPE, which by default kills the writer; ignoring

the signal turns it into EPIPE instead.

  • A named pipe is made with mkfifo, is a file whose ls -l type is p, works between unrelated

programs, blocks on open until the other side arrives, and must be deleted.

  • Message passing copies the data twice and synchronises for free. Shared memory copies nothing

and synchronises not at all.

Questions you should be able to answer

1. Which of fd[0] and fd[1] is the read end? fd[0]. It matches standard input being 0 and standard output being 1.

2. Why must pipe be called before fork? Because the child inherits the descriptors that were open when it was created. A pipe made afterwards exists only in the process that made it.

3. A program reads from a pipe in a loop and never finishes, although the writer has ended. Why? The reader still has the write end open, so the kernel does not report end of file. Each side must close the end it does not use.

4. Three writes of eight bytes were made. How many reads does the other side need? It cannot be known. A pipe is a byte stream with no boundaries: in the run on this page one read of 24 bytes took all three. The reader must delimit messages itself.

5. What does read return at end of file, and when does that happen? Zero, and it happens when the pipe is empty and every write end has been closed.

6. What happens when a process writes to a pipe with no reader? It is sent SIGPIPE, which by default kills it with no message. If the signal is ignored or handled, the write fails and sets errno to EPIPE.

munotes.in46

Practical 2: Process Communication with Pipes

7. Give two differences between an anonymous pipe and a named pipe. An anonymous pipe has no name and reaches only related processes; a named pipe is a file in a directory, so any program that can open it may use it, and it has to be deleted afterwards.

8. Why does the producer-consumer over a pipe need no semaphore, when the shared memory version needs two? Because the pipe itself blocks: a read on an empty pipe waits and a write to a full pipe waits, and the kernel does the sleeping and waking. Shared memory does nothing of the kind.

Contents This chapter on its own page

munotes.in47

Chapter Seven

Practical 2 continued: Message Queues, Blocking and Non-blocking

Syllabus topic Module 1, "Compare and contrast shared memory vs. message-passing approaches. Analyze blocking vs. non-blocking communication."

Aim

To use a System V message queue for message passing, to select a message by its type, to compare blocking with non-blocking communication, and to compare shared memory with message passing.

What you need to know before you start

[Practical 2: Process Communication with Pipes] ended on a discovery: three writes of eight bytes came out of the pipe as one read of twenty-four. A pipe carries bytes and forgets where each write ended.

A message queue is the fix. It is a list of messages held by the kernel, and a message goes in whole and comes out whole. It also does something a pipe cannot: each message carries a type, a number the sender chooses, and a receiver may ask for a particular type and leave the rest in the queue.

So the two forms of message passing divide like this:

PipeMessage queue
The unita bytea message
Boundaries keptnoyes
Orderstrictly first in, first outfirst in first out within a type
Selective receivenoyes, by type
Made bypipe or mkfifomsgget
Between unrelated programsonly a named pipeyes, with a key
Cleaned up byclosing the descriptorsmsgctl with IPC_RMID

The three calls, and the structure you must define

CallIn words
msgget(key, flags)ask for a queue, get an id back
msgsnd(id, &msg, size, flags)put a message in
msgrcv(id, &msg, size, type, flags)take a message out
msgctl(id, cmd, buf)ask about the queue, or destroy it

Every message is a structure whose first member must be a long holding the type. The kernel requires it, and the rest of the structure is yours:

struct message {
    long mtype;              /* must be first, and must be > 0 */
    char mtext[64];          /* anything you like, of any size */
};

The size you pass to msgsnd and msgrcv is the size of everything after mtype, not of the whole structure. That is the single most common mistake in this exercise: sizeof m instead of sizeof m.mtext sends eight bytes too many, and the receiver then finds its own structure filled in wrongly.

The program: three messages, sent and received whole

#include <stdio.h>
#include <string.h>
#include <sys/types.h>
#include <unistd.h>
#include <sys/wait.h>
#include <sys/ipc.h>
#include <sys/msg.h>

struct message {
    long mtype;
    char mtext[64];
};

int main(void)
{
    int q = msgget(IPC_PRIVATE, IPC_CREAT | 0600);
    if (q == -1) {
        perror("msgget");
        return 1;
    }

    if (fork() == 0) {                         /* the sender */
        struct message m;
        for (int i = 1; i <= 3; i++) {
            m.mtype = 1;
            snprintf(m.mtext, sizeof m.mtext, "item %d", i * 10);
            if (msgsnd(q, &m, sizeof m.mtext, 0) == -1) {
                perror("msgsnd");
                _exit(1);
            }
            printf("sender  : sent [%s]\n", m.mtext);
            fflush(stdout);
        }
        _exit(0);
    }

    wait(NULL);                                /* let the sender finish first */

    struct message m;
    for (int i = 1; i <= 3; i++) {
        ssize_t n = msgrcv(q, &m, sizeof m.mtext, 1, 0);
        if (n == -1) {
            perror("msgrcv");
            break;
        }
        printf("receiver: got %zd bytes, type %ld, [%s]\n", n, m.mtype, m.mtext);
    }

    msgctl(q, IPC_RMID, NULL);
    return 0;
}
munotes.in48

Practical 2 continued: Message Queues, Blocking and Non-blocking

sender  : sent [item 10]
sender  : sent [item 20]
sender  : sent [item 30]
receiver: got 64 bytes, type 1, [item 10]
receiver: got 64 bytes, type 1, [item 20]
receiver: got 64 bytes, type 1, [item 30]

Three sends, three receives. Compare that with the pipe, where three writes became one read. Each msgrcv returned 64 bytes, which is the size of mtext, because the sender asked for all 64 to be sent: a message queue delivers exactly what was posted, and the length is part of the message.

msgget(IPC_PRIVATE, IPC_CREAT | 0600) takes only a key and flags, and no size: a queue grows as messages are put into it, up to a system limit. The permission bits are the same idea as for shared memory.

The wait(NULL) in the middle is there only to make the page's output tidy, by letting all three messages be sent before any is received. It is not needed for correctness, and that is the point of the next section.

Blocking, which is what message passing gives you for nothing

A receive on an empty queue blocks: the process is put to sleep by the kernel and woken when a message arrives. A send to a full queue blocks in the same way.

That single behaviour is the reason the producer-consumer over a message queue needs no semaphore, no shared counter, and no wait.

#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>
#include <sys/wait.h>
#include <sys/ipc.h>
#include <sys/msg.h>

#define ITEMS 5

struct message {
    long mtype;
    int  item;
};

int main(void)
{
    int q = msgget(IPC_PRIVATE, IPC_CREAT | 0600);
    if (q == -1) {
        perror("msgget");
        return 1;
    }

    if (fork() == 0) {                          /* the producer */
        struct message m = { .mtype = 1, .item = 0 };
        for (int i = 1; i <= ITEMS; i++) {
            m.item = i * i;
            if (msgsnd(q, &m, sizeof m.item, 0) == -1) {
                perror("msgsnd");
                _exit(1);
            }
            printf("producer: produced %d\n", m.item);
            fflush(stdout);
        }
        m.mtype = 2;                            /* type 2 means "I am done" */
        msgsnd(q, &m, sizeof m.item, 0);
        _exit(0);
    }

    if (fork() == 0) {                          /* the consumer */
        struct message m;
        int total = 0;
        for (;;) {
            /* -2 means: the lowest type that is 2 or less, so an item
               (type 1) is always preferred over the end marker (type 2) */
            if (msgrcv(q, &m, sizeof m.item, -2, 0) == -1) {
                perror("msgrcv");
                _exit(1);
            }
            if (m.mtype == 2)
                break;
            printf("consumer: consumed %d\n", m.item);
            fflush(stdout);
            total = total + m.item;
        }
        printf("consumer: the sender has finished, the total is %d\n", total);
        fflush(stdout);
        _exit(0);
    }

    while (wait(NULL) > 0)
        ;
    msgctl(q, IPC_RMID, NULL);
    return 0;
}
munotes.in49

Practical 2 continued: Message Queues, Blocking and Non-blocking

producer: produced 1
consumer: consumed 1
producer: produced 4
consumer: consumed 4
producer: produced 9
consumer: consumed 9
producer: produced 16
consumer: consumed 16
producer: produced 25
consumer: consumed 25
consumer: the sender has finished, the total is 55

That order is one run's, not the program's. The two processes are racing, and nothing here makes them take turns. This run went back and forth, a produce and then a consume. Another run sends all five before the consumer is given the processor, and prints the five produced lines in a block. Both are correct, so the check on this page compares these lines without their order, on all ten runs.

What holds on every run is this. Each square is produced once and consumed once. The consumer takes the items in the order they were sent, because a queue is first in, first out. And the total is 55, which is 1 plus 4 plus 9 plus 16 plus 25, printed so that the program proves no item was lost or counted twice.

A tidy page is exactly what the wait(NULL) in the previous program was buying. Here there is no wait, the producer and the consumer are alive at the same time, and so the page cannot be tidy. That is not a fault to be repaired. It is the blocking receive doing its work, and it is the reason this producer-consumer needs no semaphore at all.

The end marker is the idea worth taking from this program. A pipe tells the reader it is finished by reaching end of file when the writer closes it. A message queue has no such thing: it does not know who is using it. So the producer sends one last message of a different type, and the consumer stops when it sees that type. It is the standard answer, and an examiner asks for it as "how does the consumer know when to stop".

The -2 in the consumer's msgrcv is doing real work, and the next section explains it.

Selecting a message by its type

The fourth argument of msgrcv is the type to ask for, and it has three quite different meanings depending on its sign. This program sends three messages of types 10, 20 and 30 and then takes them out in a deliberately awkward order.

munotes.in50

Practical 2 continued: Message Queues, Blocking and Non-blocking

#include <stdio.h>
#include <string.h>
#include <errno.h>
#include <sys/ipc.h>
#include <sys/msg.h>

struct message {
    long mtype;
    char mtext[32];
};

int main(void)
{
    int q = msgget(IPC_PRIVATE, IPC_CREAT | 0600);
    if (q == -1) {
        perror("msgget");
        return 1;
    }

    struct message m;
    const char *body[] = { "for the teacher", "for the student", "for anybody" };
    long types[] = { 10, 20, 30 };

    for (int i = 0; i < 3; i++) {
        m.mtype = types[i];
        snprintf(m.mtext, sizeof m.mtext, "%s", body[i]);
        if (msgsnd(q, &m, sizeof m.mtext, 0) == -1) {
            perror("msgsnd");
            return 1;
        }
    }

    /* a positive type: exactly that type */
    msgrcv(q, &m, sizeof m.mtext, 20, 0);
    printf("asked for type 20, got type %ld: [%s]\n", m.mtype, m.mtext);

    /* a negative type: the LOWEST type that is not more than its size */
    msgrcv(q, &m, sizeof m.mtext, -25, 0);
    printf("asked for the lowest type up to 25, got type %ld: [%s]\n",
           m.mtype, m.mtext);

    /* zero: whatever is at the front of the queue */
    msgrcv(q, &m, sizeof m.mtext, 0, 0);
    printf("asked for any type, got type %ld: [%s]\n", m.mtype, m.mtext);

    /* and now the queue is empty, and we ask without waiting */
    ssize_t n = msgrcv(q, &m, sizeof m.mtext, 0, IPC_NOWAIT);
    printf("the queue is empty now: msgrcv returned %zd, errno says %s\n",
           n, strerror(errno));

    msgctl(q, IPC_RMID, NULL);
    return 0;
}
asked for type 20, got type 20: [for the student]
asked for the lowest type up to 25, got type 10: [for the teacher]
asked for any type, got type 30: [for anybody]
the queue is empty now: msgrcv returned -1, errno says No message of desired type

That output is the whole rule, and it is worth putting in a table because an examiner asks it directly.

The type argumentWhat you get
a positive number tthe first message whose type is exactly t
zerothe first message in the queue, whatever its type
a negative number -tthe message with the lowest type that is not greater than t, and the first of those

The second line of the output is the one to look at twice. The queue held types 10, 20 and 30; type 20 had already been taken; asking for -25 means "the lowest type up to 25", and the answer was type 10, not type 20 and not the front of the queue. That is what makes a message type usable as a priority: send urgent work as type 1 and ordinary work as type 5, receive with a negative number, and the urgent work comes out first however late it arrived.

It is also why the producer-consumer above used -2: items are type 1 and the end marker is type 2, so as long as any item remains the consumer takes an item, and it sees the marker only when the items have run out. With a plain 0 there the consumer would take the marker as soon as it reached the front, and stop early.

munotes.in51

Practical 2 continued: Message Queues, Blocking and Non-blocking

Blocking against non-blocking

The last line of that program is the other half of MU's bullet. IPC_NOWAIT in the flags means do not block: if there is nothing to receive, fail immediately instead of waiting.

msgrcv returned -1, errno says No message of desired type

errno is ENOMSG, and the message is the system's own wording. The same flag on msgsnd means do not wait for room in a full queue, and then the error is EAGAIN.

Blocking, flags 0Non-blocking, IPC_NOWAIT
Receive, queue emptythe process sleeps until a message arrivesreturns -1 at once, errno is ENOMSG
Send, queue fullthe process sleeps until there is roomreturns -1 at once, errno is EAGAIN
The processor while waitingfree for other workbusy, if you loop
Good fora process whose only job is thisa process that has other work to get on with
The dangerwaiting for ever if nobody sendsa busy-wait loop that spins the processor

Blocking is the right default, and a student should say so and know why: a blocked process uses no processor time at all. The kernel takes it off the run queue and puts it back when the message arrives. A non-blocking receive in a loop, by contrast, asks the kernel over and over, gets -1 every time, and keeps a whole processor busy doing nothing. That pattern is called a busy-wait or a spin, and it is almost always the wrong answer.

Non-blocking is right in exactly one situation: when the process has something else to do and wants to check the queue in passing. A program drawing a screen sixty times a second cannot afford to block, so it looks, finds nothing, and carries on drawing.

Looking at a queue from outside

ipcs -q lists the message queues on the machine, as ipcs -m does for shared memory, and msgctl with IPC_STAT lets the program ask about its own queue.

#include <stdio.h>
#include <string.h>
#include <sys/ipc.h>
#include <sys/msg.h>

struct message {
    long mtype;
    char mtext[16];
};

int main(void)
{
    int q = msgget(IPC_PRIVATE, IPC_CREAT | 0600);
    if (q == -1) {
        perror("msgget");
        return 1;
    }

    struct message m = { .mtype = 1, .mtext = "" };
    for (int i = 0; i < 4; i++) {
        snprintf(m.mtext, sizeof m.mtext, "message %d", i);
        msgsnd(q, &m, sizeof m.mtext, 0);
    }

    struct msqid_ds info;
    if (msgctl(q, IPC_STAT, &info) == -1) {
        perror("msgctl IPC_STAT");
        return 1;
    }
    printf("messages waiting in the queue : %lu\n", (unsigned long) info.msg_qnum);
    printf("the most bytes it will hold   : %lu\n", (unsigned long) info.msg_qbytes);
    printf("the permission bits           : %o\n", info.msg_perm.mode & 0777);

    msgctl(q, IPC_RMID, NULL);
    return 0;
}
munotes.in52

Practical 2 continued: Message Queues, Blocking and Non-blocking

messages waiting in the queue : 4
the most bytes it will hold   : 16384
the permission bits           : 600

Four messages are waiting, nobody having received any, and the queue will hold 16384 bytes in total, which is the Linux default and is the figure a send would block on. A queue is therefore a bounded buffer as well, exactly like a pipe, and this is its bound.

A note for anyone who goes looking in the manual page: struct msqid_ds also holds the number of bytes currently in the queue, and that member is not portable. On glibc it is spelled __msg_cbytes, with two leading underscores, and asking for msg_cbytes is an error: struct msqid_ds has no member named msg_cbytes; did you mean msg_qbytes?. The three members above are the ones to rely on.

A queue left behind shows in ipcs -q and is removed with ipcrm -q <msqid>, and the same warning applies as for shared memory: a laboratory full of leaked queues eventually stops working for everybody.

Shared memory against message passing, which is MU's own bullet

This is the table to learn. It is the answer to the most likely viva question on Practical 1 and Practical 2 together.

Shared memoryMessage passing
How the data travelsboth processes read and write the same memorythe kernel holds a copy and hands it over
Copies madenonetwo, in and out
Speedfastest availableslower, but rarely the bottleneck
Synchronisationnone at all, you must add semaphoresbuilt in, the kernel blocks and wakes
Notificationnone, a reader cannot tell new data has arrivedthe receiver simply waits for it
Structure of the datawhatever you lay out in the segmentwhole messages, each with a type
Boundariesthere are nonekept, and selectable by type
Amount at a timeas much as the segment holdsone message, up to a limit
Risk of a wrong answerhigh: a race condition loses updates silentlylow, the kernel serialises the operations
Risk of a hanga deadlock over the semaphoresa receive with nobody sending
Callsshmget, shmat, shmdt, shmctlmsgget, msgsnd, msgrcv, msgctl
Best forlarge data, and many exchanges a secondcommands, requests, work items, priorities

Two sentences to remember it by:

Shared memory is a noticeboard: everybody can read and write it, nobody is told when it

changes, and two people writing at once make a mess.

munotes.in53

Practical 2 continued: Message Queues, Blocking and Non-blocking

A message queue is a pigeonhole: things go in whole, come out whole, wait until they are

collected, and can be sorted by who they are for.

Procedure

  1. Write the first program. Compile with gcc -Wall -Wextra -o mq1 mq1.c and run it. Count the

sends and the receives.

  1. Change sizeof m.mtext to sizeof m in the msgsnd call and run it again. Note what the

receiver prints, and put it back.

  1. Write the producer-consumer program. Confirm the total.
  2. Change the consumer's -2 to 0 and run it. Explain what happened to the missing items.
  3. Write the type-selection program and read the three answers carefully.
  4. Write the IPC_STAT program. Then run it with the msgctl(q, IPC_RMID, NULL) line commented

out, and find the queue with ipcs -q. Remove it with ipcrm -q.

Result

Three messages were sent and received whole, each one delivered as it was posted, which a pipe cannot do. The producer-consumer problem was solved over a message queue with no semaphore and no shared counter, because a receive on an empty queue blocks. A message was selected by exact type, by lowest type and by position, and IPC_NOWAIT on an empty queue was shown to return -1 with errno set to ENOMSG instead of waiting.

Where marks are lost

  • sizeof m instead of sizeof m.mtext. The size is of everything after mtype.
  • mtype not first in the structure, or set to 0 or a negative number. The kernel requires a

positive type in the first member.

  • Forgetting msgctl(q, IPC_RMID, NULL). The queue stays on the machine; ipcs -q shows it.
  • No end marker, so the consumer blocks for ever after the last item and the program has to be

killed.

  • Using 0 as the type in a consumer that also has an end marker, so it stops early.
  • Saying "non-blocking is better". It is not: a blocked process uses no processor, and a

non-blocking receive in a loop is a busy-wait.

  • Not checking msgsnd and msgrcv. msgrcv returns the number of bytes, and -1 on failure,

which is not the same as 0.

For the journal

Write the aim, MU's own wording, the first program and its output, and the producer-consumer program with its total. Then the type-selection program, and copy the four lines of its output into the journal because they are the answer to the viva question. Add the comparison table of shared memory against message passing; it is two marks on its own. The conclusion: a message queue keeps each message whole and blocks the receiver until one arrives, so it needs no semaphore, and it costs two copies of the data that shared memory does not.

munotes.in54

Practical 2 continued: Message Queues, Blocking and Non-blocking

Quick revision

  • A message queue is a kernel-held list of whole messages. msgget, msgsnd, msgrcv, msgctl.
  • The message structure starts with long mtype, which must be positive. The size passed to

msgsnd and msgrcv is the size of the rest, not of the whole structure.

  • A message goes in whole and comes out whole, unlike a pipe, which is a byte stream.
  • The type argument of msgrcv: positive means exactly that type, zero means the front of the

queue, negative means the lowest type not greater than its size. Negative is how priorities are done.

  • A blocking receive on an empty queue sleeps and uses no processor. IPC_NOWAIT returns -1 at

once with ENOMSG; a non-blocking send to a full queue gives EAGAIN.

  • A consumer knows the producer has finished because the producer sends a last message of a

different type. There is no end of file on a queue.

  • msgctl with IPC_STAT fills a struct msqid_ds: msg_qnum is how many messages are waiting,

msg_qbytes is the capacity, 16384 by default on Linux.

  • ipcs -q lists queues, ipcrm -q <id> removes one.
  • Shared memory: no copies, no synchronisation. Message passing: two copies, synchronisation free.

Questions you should be able to answer

1. What must the first member of a message structure be, and why? A long holding the message type. The kernel reads it to decide which receiver the message can satisfy, and it must be a positive number.

2. What size is passed to msgsnd? The size of the structure after mtype, so sizeof m.mtext and not sizeof m.

3. Three writes were made. How many receives does a message queue need, and how many did a pipe need? The queue needs three, because each message is delivered whole. The pipe took one read of 24 bytes for three writes of 8, because it is a byte stream.

4. What are the three meanings of the type argument to msgrcv? A positive number asks for exactly that type; zero asks for the message at the front of the queue; a negative number asks for the lowest type that is not greater than its absolute value.

5. How would you give some messages priority over others? Send the urgent ones with a low type number and receive with a negative type. The lowest type present is delivered first, whenever it arrived.

6. What does a non-blocking receive return on an empty queue? Minus one, with errno set to ENOMSG, whose message is "No message of desired type".

7. Is non-blocking communication better than blocking? No. A blocked process is asleep and uses no processor time; a non-blocking receive in a loop is a busy-wait that keeps a processor working for nothing. Non-blocking is right only when the process has other work to do between checks.

munotes.in55

Practical 2 continued: Message Queues, Blocking and Non-blocking

8. How does a consumer on a message queue know the producer has finished? The producer sends a final message of a distinct type, which the consumer treats as an end marker. A queue has no end of file, because the kernel does not know who is using it.

9. Give the one-line difference between shared memory and message passing. Shared memory copies nothing and synchronises nothing; message passing copies the data twice and does the synchronising for you.

Contents This chapter on its own page

munotes.in56

Chapter Eight

Practical 3: Threading and Single Thread Control Flow

Syllabus topic Module 1, "Threading and Single Thread Control Flow: Practice thread creation and basic thread lifecycle using standard libraries (e.g., pthreads or Java threads). Observe execution order, thread joining, and delays. Measure execution time for sequential vs threaded execution."

Aim

To create threads with the POSIX threads library, to observe the thread lifecycle and the order in which threads run, to join them, and to measure sequential against threaded execution.

What you need to know before you start

A process has an address space of its own. A thread does not: several threads live inside one process and share everything in it. That one sentence is the whole difference, and everything else follows from it.

Shared by all threads in a processPrivate to each thread
the codethe stack, so local variables are private
global and static variablesthe registers, including the program counter
the heap, so everything from mallocthe thread identifier
open files and socketserrno, which is per-thread on Linux
the current directorythe signal mask

So a thread is cheaper to create than a process and can share data with no system call at all, and for exactly that reason two threads that touch the same variable have the race condition of [Practical 1 continued: the Race Condition, Semaphores, and Producer and Consumer] with no shared memory segment needed. That is Practical 4.

MU names pthreads, the POSIX threads library. Two things every program needs:

  • #include <pthread.h>
  • -pthread on the compile command. Without it the link fails with

undefined reference to pthread_create.

The calls

CallIn words
pthread_create(&t, attr, fn, arg)start a new thread running fn(arg)
pthread_join(t, &ret)wait for that thread and collect what it returned
pthread_exit(p)end this thread, returning p
pthread_self()this thread's own identifier
pthread_detach(t)say that nobody will ever join this thread

These do not return -1 and they do not set errno. They return 0 on success and an error number on failure, which is the opposite convention from every other call in Module 1. So the check is if (pthread_create(...) != 0), and to print the reason use strerror(rc) on the returned value rather than perror.

The lifecycle, in the words an examiner wants

  1. New. pthread_create has been called; the thread exists.
  2. Runnable. It is ready and waiting for a processor.
  3. Running. It is on a processor.
  4. Blocked. It is waiting for something: a mutex, a read, a sleep.
  5. Terminated. Its function has returned, or it called pthread_exit. It still holds its

return value.

  1. Joined. Somebody has called pthread_join, which collects the return value and releases

the last of the thread's resources.

Stages 5 and 6 are the pair worth understanding, because they are the thread version of the zombie in [Processes: fork, wait, exec, and Why Two Programs Need to Talk]: a thread that has finished and has not been joined still holds resources. The word for it is not detached, and the fix is either pthread_join or pthread_detach.

munotes.in57

Practical 3: Threading and Single Thread Control Flow

The first program: one thread, created and joined

#include <stdio.h>
#include <string.h>
#include <pthread.h>

static void *worker(void *arg)
{
    const char *name = arg;
    printf("the thread says: hello, I am %s\n", name);
    return NULL;
}

int main(void)
{
    pthread_t t;

    int rc = pthread_create(&t, NULL, worker, "worker one");
    if (rc != 0) {
        fprintf(stderr, "pthread_create: %s\n", strerror(rc));
        return 1;
    }

    printf("main   says: I made a thread and I am waiting for it\n");

    pthread_join(t, NULL);

    printf("main   says: the thread has finished\n");
    return 0;
}
main   says: I made a thread and I am waiting for it
the thread says: hello, I am worker one
main   says: the thread has finished

Those three lines are printed here in the order they came out on the first run, and it is worth being clear about which parts of that order are guaranteed and which are not.

  • The third line is guaranteed to be last. pthread_join does not return until the thread has

finished.

  • The first two can come in either order, because as soon as pthread_create returns there

are two threads both ready to print. And they did: the first draft of this page claimed all ten runs gave the order above, and the checker refused it, because run 3 of 5 printed the thread first. The program is not wrong when that happens, and a book that printed one order as a fact would have been.

That is the honest answer to MU's bullet about observing execution order, and the next section makes it unmistakable.

The thread function's shape is fixed: it takes a void and returns a void . Every thread function in every program looks like void name(void arg), and anything else will not compile.

Execution order, which nothing guarantees

Four threads, each printing one line, all joined afterwards.

#include <stdio.h>
#include <pthread.h>

static void *say(void *arg)
{
    long n = (long) arg;
    printf("thread %ld is running\n", n);
    return NULL;
}

int main(void)
{
    pthread_t t[4];

    for (long i = 1; i <= 4; i++) {
        if (pthread_create(&t[i - 1], NULL, say, (void *) i) != 0) {
            perror("pthread_create");
            return 1;
        }
    }
    for (int i = 0; i < 4; i++)
        pthread_join(t[i], NULL);

    printf("all four have finished\n");
    return 0;
}
thread 1 is running
thread 2 is running
thread 3 is running
thread 4 is running
all four have finished

That order is one of many. Run eight times on the machine this page was checked on, the four thread lines came out in seven different orders. The page prints them in numerical order because a book has to print something, and the checker compares the lines as a set: it requires that each of the four appears exactly once on every run, and leaves the order open, because the order is not a property of the program.

munotes.in58

Practical 3: Threading and Single Thread Control Flow

The last line is different. It is after four joins, so it is always last, and that is the point of joining.

(void *) i passes the number itself as the pointer value, and (long) arg takes it out again. It is a common shortcut and it is safe for a small integer. What is not safe, and is the classic bug in this exercise, is passing the address of the loop variable:

pthread_create(&t[i], NULL, say, &i);     /* WRONG */

Every thread then gets the same address, the loop keeps changing what is at that address, and the threads print whatever i happens to be when each of them looks. The answers are not merely unordered, they are wrong, and often all the same.

What threads share

This is the program that shows the difference from fork in one line of output. Compare it with the counter in [Processes: fork, wait, exec, and Why Two Programs Need to Talk], where the child's change was invisible to the parent.

#include <stdio.h>
#include <pthread.h>

static int shared = 0;                   /* a global: every thread sees it */

static void *bump(void *arg)
{
    (void) arg;                          /* unused, and said so */
    shared = shared + 100;
    printf("thread: I set shared to %d\n", shared);
    return NULL;
}

int main(void)
{
    pthread_t t;

    printf("main  : shared starts at %d\n", shared);
    if (pthread_create(&t, NULL, bump, NULL) != 0)
        return 1;
    pthread_join(t, NULL);
    printf("main  : after the thread, shared is %d\n", shared);
    return 0;
}
main  : shared starts at 0
thread: I set shared to 100
main  : after the thread, shared is 100

One hundred, not zero. The thread's change is visible in main, because there is only one shared and both threads are looking at it. That is the whole reason threads exist, and the whole reason they are dangerous.

(void) arg; on its own line is how a C program says "I know this parameter is unused". Without it, -Wextra warns, and a warning is not allowed in this book.

Passing an argument in and getting a result out

A thread function takes one void and returns one void , which looks restrictive until you pass the address of a structure.

#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>

struct job {
    int from;
    int to;
};

static void *sum_range(void *arg)
{
    struct job *j = arg;

    long *total = malloc(sizeof *total);
    if (total == NULL)
        return NULL;

    *total = 0;
    for (int i = j->from; i <= j->to; i++)
        *total = *total + i;

    return total;                        /* the caller must free this */
}

int main(void)
{
    struct job j = { 1, 100 };
    pthread_t t;

    if (pthread_create(&t, NULL, sum_range, &j) != 0)
        return 1;

    void *result;
    pthread_join(t, &result);

    long *total = result;
    printf("the thread added 1 to 100 and returned %ld\n", *total);
    free(total);
    return 0;
}
munotes.in59

Practical 3: Threading and Single Thread Control Flow

the thread added 1 to 100 and returned 5050

Five thousand and fifty is right: the sum of 1 to 100 is 100 times 101 divided by 2.

The return value is malloced, and that is not laziness. A thread's stack is gone the moment it finishes, so returning the address of a local variable returns a pointer to memory that no longer exists. The three safe ways to get a result out of a thread:

  1. malloc it in the thread and free it in the joiner, as above.
  2. Write it into a variable the caller owns, whose address was passed in, which is what the next

two chapters do.

  1. Write it into a global or a static array, indexed by the thread's number.

Delays, and why sleep is not synchronisation

MU's bullet asks for delays to be observed, and the honest lesson is a warning.

#include <stdio.h>
#include <time.h>
#include <pthread.h>

static void *slow(void *arg)
{
    long n = (long) arg;
    struct timespec pause = { 0, 0 };
    pause.tv_nsec = n * 100000000L;      /* n tenths of a second */
    nanosleep(&pause, NULL);
    printf("thread %ld woke up after %ld tenths of a second\n", n, n);
    fflush(stdout);
    return NULL;
}

int main(void)
{
    pthread_t t[3];

    for (long i = 1; i <= 3; i++)
        pthread_create(&t[i - 1], NULL, slow, (void *) i);

    for (int i = 0; i < 3; i++)
        pthread_join(t[i], NULL);

    printf("main: all three are done\n");
    return 0;
}
thread 1 woke up after 1 tenths of a second
thread 2 woke up after 2 tenths of a second
thread 3 woke up after 3 tenths of a second
main: all three are done

Here the sleeps are far enough apart that the order comes out the same every time, and that is exactly what makes sleep so tempting and so wrong. A student who wants thread A to finish before thread B writes sleep(1) in B, sees it work, and ships it. It works until the machine is busy, or the data is bigger, or the marks are being given.

A delay is not a synchronisation. It makes a race less likely and no less possible. The

tools that actually order two threads are pthread_join, a mutex, a semaphore and a condition

variable, and they are the subject of the next three chapters.

munotes.in60

Practical 3: Threading and Single Thread Control Flow

nanosleep is used rather than sleep because sleep takes whole seconds. The structure takes seconds and nanoseconds, and tv_nsec must be less than one thousand million or the call fails with EINVAL.

Sequential against threaded: the measurement

MU's third bullet. This is the one place in the chapter where the answer depends on the machine, so the program measures and the page reports what it measured.

Work that waits

Four tasks, each of which does nothing for one second. That stands for what real programs actually spend their time on: waiting for a disk, a database or a network.

#include <stdio.h>
#include <time.h>
#include <pthread.h>

#define TASKS 4

static void *fetch(void *arg)
{
    long k = (long) arg;
    struct timespec one = { 1, 0 };
    nanosleep(&one, NULL);               /* waiting for something slow */
    printf("task %ld has its data\n", k);
    fflush(stdout);
    return NULL;
}

static double now(void)
{
    struct timespec ts;
    clock_gettime(CLOCK_MONOTONIC, &ts);
    return (double) ts.tv_sec + (double) ts.tv_nsec / 1e9;
}

int main(void)
{
    double t0 = now();
    for (long k = 1; k <= TASKS; k++)
        fetch((void *) k);
    printf("one after another : %.0f seconds\n", now() - t0);

    pthread_t t[TASKS];
    double t1 = now();
    for (long k = 1; k <= TASKS; k++)
        pthread_create(&t[k - 1], NULL, fetch, (void *) k);
    for (int k = 0; k < TASKS; k++)
        pthread_join(t[k], NULL);
    printf("all four at once  : %.0f second\n", now() - t1);
    return 0;
}
task 1 has its data
task 2 has its data
task 3 has its data
task 4 has its data
one after another : 4 seconds
task 1 has its data
task 2 has its data
task 3 has its data
task 4 has its data
all four at once  : 1 second

Four seconds against one, every time. That is a fourfold speedup and it does not depend on the machine having four processors, because the four threads are not computing, they are asleep. A sleeping thread occupies no processor at all, so all four can be asleep at once on a machine with one.

This is the single most useful thing to know about threads in practice: threads overlap waiting. A program that fetches four web pages, or reads four files, or serves four users, is four times faster with four threads on any machine.

Work that computes

Now the same experiment with arithmetic instead of sleeping, and here the answer changes.

#include <stdio.h>
#include <time.h>
#include <unistd.h>
#include <pthread.h>

#define THREADS 2
#define ROUNDS  30000000UL

static unsigned long answer[THREADS];

/* a chain in which each step needs the one before it, so the compiler
   cannot skip the loop and cannot do several steps at a time */
static void *work(void *arg)
{
    long k = (long) arg;
    unsigned long x = 12345UL + (unsigned long) k;
    for (unsigned long i = 0; i < ROUNDS; i++)
        x = x * 6364136223846793005UL + 1442695040888963407UL;
    answer[k] = x;
    return NULL;
}

static double now(void)
{
    struct timespec ts;
    clock_gettime(CLOCK_MONOTONIC, &ts);
    return (double) ts.tv_sec + (double) ts.tv_nsec / 1e9;
}

int main(void)
{
    printf("processors the system reports   : %ld\n",
           sysconf(_SC_NPROCESSORS_ONLN));

    double t0 = now();
    for (long k = 0; k < THREADS; k++)
        work((void *) k);
    double seq = now() - t0;
    unsigned long a = answer[0] + answer[1];

    pthread_t t[THREADS];
    double t1 = now();
    for (long k = 0; k < THREADS; k++)
        pthread_create(&t[k], NULL, work, (void *) k);
    for (int k = 0; k < THREADS; k++)
        pthread_join(t[k], NULL);
    double par = now() - t1;
    unsigned long b = answer[0] + answer[1];

    printf("one chunk after another, clock  : %.2f seconds\n", seq);
    printf("both chunks in threads, clock   : %.2f seconds\n", par);
    printf("the two answers agree           : %s\n", a == b ? "yes" : "NO");
    return 0;
}
munotes.in61

Practical 3: Threading and Single Thread Control Flow

processors the system reports   : 2
one chunk after another, clock  : 1.18 seconds
both chunks in threads, clock   : 1.29 seconds
the two answers agree           : yes

No speedup at all, and the threaded version was slightly slower.

That is a real result on a real machine and the chapter is not going to pretend otherwise. The reason is worth understanding, because it is the most important limit on threading:

Two threads doing arithmetic are only faster than one if there are

two processors free to run them. The lab container this book is checked in reports two

processors, and measuring it directly shows that two processes doing the same work take twice as

long as one, so it has one processor's worth of throughput to give. With one processor, two

threads share it, and the total time is the same plus the cost of creating and switching between

them.

On your own laboratory machine, which probably has four or eight real cores and nothing else running, the same program should show the threaded time at close to half the sequential time. Run it and see: that is the experiment, and either answer is a correct result to write in the journal as long as you also write the number of processors.

Three honest conclusions from the pair of measurements, and they are what an examiner is looking for:

  1. Threads always help with work that waits, on any machine, because waiting needs no

processor.

  1. Threads help with work that computes only up to the number of processors available. Eight

threads on four cores is not twice as fast as four.

  1. Threads are never free. Creating one costs time, and switching between them costs time, so
munotes.in62

Practical 3: Threading and Single Thread Control Flow

a job too small to notice is slower with threads than without.

And one about measurement itself, which was learned the hard way while this page was being checked: the first version of this program used a simpler loop, s += i % 7, and with optimisation turned on the compiler removed most of the work, so the whole measurement was of nothing. A timing experiment has to be checked by looking at whether the answer it computes is actually used, and this version prints the answers and compares them for exactly that reason.

Detaching a thread, and the resources a finished thread holds

A thread that has finished is not entirely gone: its return value and a little book-keeping are kept until somebody joins it. A program that creates a thousand threads and joins none of them runs out of memory.

#include <stdio.h>
#include <string.h>
#include <time.h>
#include <pthread.h>

static void *quick(void *arg)
{
    (void) arg;
    return NULL;
}

int main(void)
{
    int made = 0;

    for (int i = 0; i < 2000; i++) {
        pthread_t t;
        int rc = pthread_create(&t, NULL, quick, NULL);
        if (rc != 0) {
            printf("pthread_create failed at thread %d: %s\n", i, strerror(rc));
            break;
        }
        pthread_detach(t);               /* nobody will ever join it */
        made++;
    }

    printf("created and detached %d threads without running out\n", made);
    return 0;
}
created and detached 2000 threads without running out

pthread_detach(t) tells the library that nobody will join this thread, so it may release everything the moment the thread ends. Two thousand threads came and went and nothing accumulated.

The rule: every thread is either joined or detached. A joinable thread that nobody joins is a leak, exactly like a fork with no wait. Which of the two to choose is a question about the program:

Join it whenDetach it when
you need its answerit writes its answer somewhere itself
you must not go on until it finishesit is a background job, such as writing a log
you want the errors it reportsnothing depends on when it ends

An attribute can also be set before creation, with pthread_attr_setdetachstate, which is the tidier way when every thread of a kind is to be detached.

Threads against processes

The comparison table, and a likely viva question.

ThreadProcess
Created bypthread_createfork
Address spaceshared with its siblingsits own, a copy at the moment of the fork
Sharing dataany global or heap variableneeds shared memory or a pipe
Cost to createsmalllarger, though Linux makes it cheap
Cost to switchsmall, no address space changelarger
A crashtakes the whole process downtakes only that process down
Waiting for itpthread_joinwait
Leak if not collecteda thread that is neither joined nor detacheda zombie
Synchronisationmutex, condition variable, semaphoresemaphore, or the pipe itself
munotes.in63

Practical 3: Threading and Single Thread Control Flow

The line that matters most in practice is the crash: one thread that writes past the end of an array kills every thread in the process, because they are all in the same address space. That is why a web browser puts each tab in a process and not in a thread.

Procedure

  1. Write the first program. Compile with gcc -Wall -Wextra -pthread -o thread1 thread1.c.
  2. Leave -pthread off the command deliberately and read the error. Put it back.
  3. Run the four-thread program ten times and write down the order each time.
  4. Change the fourth argument of pthread_create to &i and run it again. Note that the numbers

are now wrong, not merely out of order, and put it back.

  1. Write the shared-global program and confirm main sees 100.
  2. Write the summing program and confirm 5050. Change the thread to return the address of a local

variable instead and note that the answer is now rubbish.

  1. Write the waiting measurement and record both times.
  2. Write the computing measurement, record both times and the number of processors your

machine reports, and write one sentence explaining the result you got.

  1. Write the detaching program. Remove the pthread_detach line and run it again.

Result

Threads were created and joined, and the thread lifecycle from creation to joining was followed. A global variable changed by a thread was seen by main, which processes cannot do. The order in which four threads ran was found to differ from run to run, and joining was the only thing that imposed an order. An argument was passed into a thread and a result returned from it. Four tasks that wait took four seconds one after another and one second in parallel, on any machine; two tasks that compute took the same time either way on a machine with one processor's worth of throughput.

Where marks are lost

  • Forgetting -pthread. The program does not link, and the message names pthread_create,

which looks like a mistake in the program.

  • Checking pthread_create for -1. It returns 0 on success and an error number on failure,

and never sets errno.

  • Passing &i from a loop. Every thread reads the same changing variable.
  • Returning the address of a local variable from a thread function. The stack is gone.
  • Neither joining nor detaching. A finished thread holds resources until one of the two

happens.

  • Using sleep to make threads run in a particular order and calling it synchronisation.
  • Claiming a speedup from one measurement, or without saying how many processors the machine
munotes.in64

Practical 3: Threading and Single Thread Control Flow

has. An examiner who knows the machine has one core will ask.

  • Writing the thread function with the wrong shape. It is void f(void arg) and nothing

else.

For the journal

Write the aim, MU's own wording, the first program and its output, and the four-thread program with the ten orders you observed, as a list. That list is the evidence for the bullet about execution order and it is worth more than any sentence. Then the two measurements, with the number of processors your machine reports beside them, and one sentence explaining the result. The conclusion: threads share the address space, so they need no shared memory to exchange data and no order is guaranteed between them; joining is what orders them; and threading speeds up waiting on any machine but speeds up computing only up to the number of processors.

Quick revision

  • A thread shares the code, the globals, the heap and the open files of its process, and has its

own stack, registers and thread identifier.

  • #include <pthread.h> and compile with -pthread.
  • The pthread calls return 0 on success and an error number on failure. They do not set

errno.

  • A thread function is void f(void arg), always.
  • Lifecycle: new, runnable, running, blocked, terminated, joined. A terminated thread that is

neither joined nor detached holds resources, like a zombie process.

  • Nothing orders two threads except pthread_join, a mutex, a semaphore or a condition variable.

A sleep is not a synchronisation.

  • Pass a small integer as (void *) i and read it back as (long) arg; never pass the address of

a loop variable.

  • Return a result through malloced memory, or through a variable the caller owns, never through

the thread's own stack.

  • Threads overlap waiting on any machine, and overlap computing only up to the number of

processors. Creating and switching them is not free.

  • One thread crashing kills the whole process, which a separate process would not.

Questions you should be able to answer

1. What does a thread share with the other threads of its process, and what does it keep to itself? It shares the code, global and static variables, the heap and the open files. It keeps its own stack, so local variables are private, its own registers and its own thread identifier.

2. How does pthread_create report failure? It returns an error number, and 0 on success. It does not return -1 and it does not set errno, so strerror on the returned value is how the reason is printed.

3. Four threads each print one line. What order do the lines come out in? Any order. On the machine this page was checked on, eight runs gave seven different orders. Only pthread_join imposes an order.

munotes.in65

Practical 3: Threading and Single Thread Control Flow

4. Why must a thread not return the address of one of its local variables? Because its stack is released when it finishes, so the address points at memory that is no longer the variable.

5. What is wrong with passing &i to every thread in a loop? They all receive the same address, and the loop keeps changing what is there, so each thread reads whatever value i has when it happens to look.

6. A thread has finished and nobody has joined it. What is the problem? Its return value and its book-keeping are still held, so it is a leak. Every thread must be either joined or detached.

7. Four tasks that each wait one second: how long with one thread and how long with four? Four seconds and one second, on any machine, because a sleeping thread needs no processor.

8. Two threads doing arithmetic on a machine with one processor: how much faster? Not faster at all, and slightly slower once the cost of creating and switching them is counted. Computing overlaps only when there are processors free.

9. Why does a web browser use a separate process for each tab rather than a thread? Because threads share one address space, so a crash in one takes down the whole process. A separate process fails on its own.

Contents This chapter on its own page

munotes.in66

Chapter Nine

Practical 4: Multi-threading and Fibonacci Generation

Syllabus topic Module 1, "Multi-threading and Fibonacci Generation: Implement multi-threading to generate and print Fibonacci sequences. Explore thread safety, synchronization when accessing shared variables. Introduce concepts of thread pooling and task delegation."

Aim

To generate and print Fibonacci numbers with several threads, to see what goes wrong when threads share a variable without synchronisation and to correct it with a mutex, and to build a small thread pool that delegates tasks to its workers.

What you need to know before you start

The Fibonacci numbers start 0 and 1, and every number after that is the sum of the two before it: 0, 1, 1, 2, 3, 5, 8, 13, 21, 34. In symbols, F(0) is 0, F(1) is 1, and F(n) is F(n-1) + F(n-2).

They are set here because they are the smallest useful example of two different kinds of work:

  • Generating the sequence is inherently sequential. Each term needs the one before it, so

eight threads cannot produce eight terms at once. Anybody who says "we will use threads to make Fibonacci faster" has misunderstood the sequence.

  • Computing separate terms is parallel. F(10), F(20), F(30) and F(40) are four independent

jobs, and four threads can do them at the same time.

That distinction is the first mark in this exercise, and it is why the chapter has both programs.

Thread safety is the other word in MU's bullet. A piece of code is thread safe if it is still correct when several threads run it at once. Code that touches only its own local variables is thread safe for free, because every thread has its own stack. Code that touches a global, a static, or anything reached through a shared pointer is not thread safe until it is made so.

Generating the sequence: one thread, one array

#include <stdio.h>
#include <pthread.h>

#define N 15

static unsigned long fib[N];          /* shared: main will read it */

static void *generate(void *arg)
{
    (void) arg;
    fib[0] = 0;
    fib[1] = 1;
    for (int i = 2; i < N; i++)
        fib[i] = fib[i - 1] + fib[i - 2];
    return NULL;
}

int main(void)
{
    pthread_t t;

    if (pthread_create(&t, NULL, generate, NULL) != 0) {
        perror("pthread_create");
        return 1;
    }
    pthread_join(t, NULL);            /* the join is the synchronisation */

    printf("the first %d Fibonacci numbers, generated by a thread:\n", N);
    for (int i = 0; i < N; i++)
        printf("%lu%s", fib[i], i == N - 1 ? "\n" : " ");
    return 0;
}
the first 15 Fibonacci numbers, generated by a thread:
0 1 1 2 3 5 8 13 21 34 55 89 144 233 377

There is no mutex in that program and it does not need one, and being able to say why is worth a mark.

The array is shared, and two threads do touch it: the worker writes it and main reads it. But they never do so at the same time, because pthread_join does not return until the worker has finished. The join is a synchronisation, and it is the cheapest one there is. A mutex added here would cost time and protect nothing.

munotes.in67

Practical 4: Multi-threading and Fibonacci Generation

The rule this illustrates is the useful one: it is not sharing that needs protecting, it is sharing at the same time.

unsigned long rather than int, because F(47) is 2971215073, which is more than an int holds on any machine a student will meet. Even unsigned long runs out at F(93). A program that prints F(100) needs a bigger type or its own addition.

Computing separate terms: four threads, four answers

Here the work really is parallel, and each thread is given a small structure of its own to write its answer into.

#include <stdio.h>
#include <pthread.h>

#define WORKERS 4

struct task {
    int           n;                  /* which Fibonacci number to compute */
    unsigned long answer;             /* where this thread puts its result */
};

static unsigned long fib_of(int n)
{
    unsigned long a = 0, b = 1;
    for (int i = 0; i < n; i++) {
        unsigned long c = a + b;
        a = b;
        b = c;
    }
    return a;
}

static void *compute(void *arg)
{
    struct task *t = arg;
    t->answer = fib_of(t->n);         /* only this thread writes this slot */
    return NULL;
}

int main(void)
{
    struct task job[WORKERS] = { { 10, 0 }, { 20, 0 }, { 30, 0 }, { 40, 0 } };
    pthread_t t[WORKERS];

    for (int i = 0; i < WORKERS; i++)
        if (pthread_create(&t[i], NULL, compute, &job[i]) != 0) {
            perror("pthread_create");
            return 1;
        }

    for (int i = 0; i < WORKERS; i++)
        pthread_join(t[i], NULL);

    for (int i = 0; i < WORKERS; i++)
        printf("thread %d: Fibonacci number %d is %lu\n",
               i + 1, job[i].n, job[i].answer);
    return 0;
}
thread 1: Fibonacci number 10 is 55
thread 2: Fibonacci number 20 is 6765
thread 3: Fibonacci number 30 is 832040
thread 4: Fibonacci number 40 is 102334155

That output is in order on every run, and there is still no mutex. Two design decisions did that, and they are the two habits worth taking from this chapter.

Each thread has a slot of its own. job[0] is written only by the first thread, job[1] only by the second. Threads that never touch the same memory cannot race, however many of them there are. This is the best kind of thread safety: the kind you get by dividing the data rather than by locking it.

Main prints after joining them all. The threads finish in whatever order they like, and the printing is done afterwards by one thread in index order, so the output is tidy without any ordering being imposed on the work. Compare [Practical 3: Threading and Single Thread Control Flow], where the threads printed for themselves and the order was different on every run.

munotes.in68

Practical 4: Multi-threading and Fibonacci Generation

&job[i] is passed, not &i. Passing &i would give every thread the same address, and the loop would keep changing what was there. That bug is described in Practical 3 and it is worth looking for in your own code every time you write this loop.

Thread safety: the shared counter that loses updates

Now let several threads write the same variable, with nothing to stop them. This is the thread version of the race condition from [Practical 1 continued: the Race Condition, Semaphores, and Producer and Consumer], and it is easier to produce here because threads need no shared memory segment: a plain global is enough.

Four threads, each adding one to the counter two thousand times. The right answer is eight thousand. The program runs the whole experiment twenty times and reports.

#include <stdio.h>
#include <sched.h>
#include <pthread.h>

#define THREADS 4
#define EACH    2000
#define TRIALS  20

static long counter;                  /* shared by every thread, unprotected */

static void *bump(void *arg)
{
    (void) arg;
    for (int i = 0; i < EACH; i++) {
        long v = counter;             /* read  */
        sched_yield();                /* the window the race lives in */
        counter = v + 1;              /* write */
    }
    return NULL;
}

int main(void)
{
    int right = 0;
    long lowest = (long) THREADS * EACH;

    for (int trial = 0; trial < TRIALS; trial++) {
        counter = 0;
        pthread_t t[THREADS];
        for (int i = 0; i < THREADS; i++)
            if (pthread_create(&t[i], NULL, bump, NULL) != 0)
                return 1;
        for (int i = 0; i < THREADS; i++)
            pthread_join(t[i], NULL);

        if (counter == (long) THREADS * EACH)
            right++;
        if (counter < lowest)
            lowest = counter;
    }

    printf("threads, each adding %d      : %d\n", EACH, THREADS);
    printf("the right answer each time    : %d\n", THREADS * EACH);
    printf("trials that gave it, out of %d : %d\n", TRIALS, right);
    printf("the lowest total seen         : %ld\n", lowest);
    printf("updates were lost             : %s\n", right < TRIALS ? "yes" : "no");
    return 0;
}
threads, each adding 2000      : 4
the right answer each time    : 8000
trials that gave it, out of 20 : 0
the lowest total seen         : 2000
updates were lost             : yes

Zero out of twenty, and the worst run reached only a quarter of the right total. Two thousand out of eight thousand means that on that trial, three of the four threads' work was thrown away entirely.

sched_yield() is in the loop for the reason recorded in Practical 1: without it the loop is so fast that the threads may not overlap, and a program that prints the right answer once teaches a student the opposite of the truth.

munotes.in69

Practical 4: Multi-threading and Fibonacci Generation

The mutex

A mutex, short for mutual exclusion, is a lock. One thread holds it at a time; a thread that asks for a held mutex waits until it is released.

CallIn words
pthread_mutex_init(&m, NULL)prepare a mutex, or use the initialiser below instead
pthread_mutex_lock(&m)take it, waiting if somebody else has it
pthread_mutex_trylock(&m)take it if it is free, and return EBUSY at once if not
pthread_mutex_unlock(&m)release it
pthread_mutex_destroy(&m)finished with it

A mutex that lives for the whole program needs no init at all: PTHREAD_MUTEX_INITIALIZER sets it up where it is declared, and that is what the next program uses.

A mutex and a binary semaphore do the same job and are not the same thing, and an examiner may ask for the difference:

MutexBinary semaphore
Has an owneryes, only the locking thread may unlock itno, any thread may signal it
Used forprotecting a critical sectionprotecting a section, and also signalling between threads
Starting valueunlockedwhatever you set it to
Across processesonly with extra worknaturally, with System V or named POSIX semaphores
In this modulePracticals 4, 5 and 6, inside one processPractical 1, between processes

The counter, corrected

The same program, with a mutex round the three lines that touch the counter. Nothing else has changed, including the sched_yield.

#include <stdio.h>
#include <sched.h>
#include <pthread.h>

#define THREADS 4
#define EACH    2000

static long counter;
static pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;

static void *bump(void *arg)
{
    (void) arg;
    for (int i = 0; i < EACH; i++) {
        pthread_mutex_lock(&lock);        /* enter the critical section */
        long v = counter;
        sched_yield();
        counter = v + 1;
        pthread_mutex_unlock(&lock);      /* leave it */
    }
    return NULL;
}

int main(void)
{
    pthread_t t[THREADS];

    for (int i = 0; i < THREADS; i++)
        if (pthread_create(&t[i], NULL, bump, NULL) != 0) {
            perror("pthread_create");
            return 1;
        }
    for (int i = 0; i < THREADS; i++)
        pthread_join(t[i], NULL);

    printf("expected %d, got %ld\n", THREADS * EACH, counter);
    return 0;
}
expected 8000, got 8000

Exactly eight thousand, on all ten runs. The sched_yield is still there, still inviting the operating system to switch threads in the middle of the critical section, and it no longer matters: any thread that is switched in finds the mutex held and waits.

Four rules about using a mutex, each of which is a mark:

  1. Lock and unlock in the same function, on every path out of it. A return between the lock

and the unlock leaves the mutex held for ever and every other thread blocks.

munotes.in70

Practical 4: Multi-threading and Fibonacci Generation

  1. Hold it for as short a time as possible. Everything inside the lock happens one thread at a

time, so a long critical section throws the benefit of threading away. Never do input, output or a sleep while holding one.

  1. Lock the same mutex for the same data, everywhere. Two functions that protect the same

variable with two different mutexes protect nothing.

  1. Take several mutexes in the same order in every thread. Two threads that take A then B and

B then A will deadlock, and that is the subject of [Practical 6: the Readers-Writers Problem].

What a mutex costs

The corrected program is slower than the broken one, and honestly so: every increment now costs a lock and an unlock, and with four threads on a machine with one processor's worth of throughput the whole loop runs one thread at a time. That is not a defect of the mutex, it is the nature of the problem. A counter that every thread must update cannot be parallelised; it can only be made correct.

The way out, where the problem allows it, is the one the four-thread Fibonacci program used: give each thread its own variable and add the results up once at the end. No lock in the hot loop at all, one addition afterwards. That pattern has a name, reduction, and it is the first thing to reach for when a shared counter appears in a threaded program.

Thread pooling and task delegation

MU's third bullet. The programs so far create one thread per piece of work, and that is fine for four pieces. It is wrong for four thousand: creating a thread is not free, and four thousand threads on a four-core machine spend their lives switching rather than working.

A thread pool creates a fixed number of threads once and feeds them work. Task delegation is the handing out: the pool holds a list of tasks, and each worker takes the next one whenever it is free.

#include <stdio.h>
#include <pthread.h>

#define WORKERS 3
#define TASKS   8

struct pool {
    int             next;             /* the next task nobody has taken */
    int             done;             /* how many have been finished */
    unsigned long   answer[TASKS];    /* one slot per task */
    pthread_mutex_t lock;             /* protects next and done */
    pthread_cond_t  all_done;         /* main waits on this */
};

static unsigned long fib_of(int n)
{
    unsigned long a = 0, b = 1;
    for (int i = 0; i < n; i++) {
        unsigned long c = a + b;
        a = b;
        b = c;
    }
    return a;
}

static void *worker(void *arg)
{
    struct pool *p = arg;

    for (;;) {
        /* take the next task: this is the only shared thing touched */
        pthread_mutex_lock(&p->lock);
        int task = (p->next < TASKS) ? p->next++ : -1;
        pthread_mutex_unlock(&p->lock);

        if (task < 0)
            break;                    /* no work left: this worker retires */

        /* the work itself is done OUTSIDE the lock */
        unsigned long r = fib_of(10 * (task + 1));

        pthread_mutex_lock(&p->lock);
        p->answer[task] = r;
        p->done++;
        if (p->done == TASKS)
            pthread_cond_signal(&p->all_done);
        pthread_mutex_unlock(&p->lock);
    }
    return NULL;
}

int main(void)
{
    struct pool p = { .next = 0, .done = 0 };
    pthread_mutex_init(&p.lock, NULL);
    pthread_cond_init(&p.all_done, NULL);

    pthread_t t[WORKERS];
    for (int i = 0; i < WORKERS; i++)
        if (pthread_create(&t[i], NULL, worker, &p) != 0) {
            perror("pthread_create");
            return 1;
        }

    /* wait until every task has been finished */
    pthread_mutex_lock(&p.lock);
    while (p.done < TASKS)
        pthread_cond_wait(&p.all_done, &p.lock);
    pthread_mutex_unlock(&p.lock);
    printf("main: all %d tasks are finished\n", TASKS);

    for (int i = 0; i < WORKERS; i++)
        pthread_join(t[i], NULL);

    for (int i = 0; i < TASKS; i++)
        printf("task %d: Fibonacci number %2d is %lu\n",
               i + 1, 10 * (i + 1), p.answer[i]);

    pthread_mutex_destroy(&p.lock);
    pthread_cond_destroy(&p.all_done);
    return 0;
}
munotes.in71

Practical 4: Multi-threading and Fibonacci Generation

main: all 8 tasks are finished
task 1: Fibonacci number 10 is 55
task 2: Fibonacci number 20 is 6765
task 3: Fibonacci number 30 is 832040
task 4: Fibonacci number 40 is 102334155
task 5: Fibonacci number 50 is 12586269025
task 6: Fibonacci number 60 is 1548008755920
task 7: Fibonacci number 70 is 190392490709135
task 8: Fibonacci number 80 is 23416728348467685

Three threads did eight tasks, and the result was byte-identical on all five runs, although which worker did which task is different every time and cannot be known from the output. That is the point of a pool: the answers are deterministic and the scheduling is not.

The three ideas in that program

One counter hands out the work. p->next++ inside the mutex is the whole of task delegation. Each worker takes a number, and the mutex guarantees no two workers take the same one. It is exactly the shared-counter problem of the previous section, and it is the one place a lock is unavoidable.

The work is done outside the lock. fib_of is called after the unlock, not before it. Had it been inside, the three workers would have taken turns and the pool would have been three threads doing one thread's work. This is rule 2 above, and it is the difference between a thread pool and a very slow loop.

A condition variable is how main waits. Main must not go on until all eight are done, and the choices are:

WayWhat is wrong with it
while (p.done < TASKS);a busy-wait: it burns a whole processor asking the same question
while (p.done < TASKS) sleep(1);wastes up to a second, and reads done without the lock
pthread_cond_waitcorrect: the thread sleeps and the kernel wakes it
munotes.in72

Practical 4: Multi-threading and Fibonacci Generation

A condition variable is a place to wait for something to become true. Three things about it are always the same, and all three are in the program above:

  • It is always used with a mutex. pthread_cond_wait(&cv, &lock) is called with the lock

held; it releases the lock while it sleeps and takes it again before returning. That is what makes testing the condition safe.

  • The wait is in a while loop, never an if. A waiting thread can be woken when the

condition is still false, which is called a spurious wakeup, so it must test again.

  • The signaller signals while holding the lock, and pthread_cond_signal wakes one waiter

where pthread_cond_broadcast wakes all of them.

How many workers

The useful rule of thumb, and it follows from the measurement in Practical 3:

  • Work that computes: as many workers as the machine has processors.

sysconf(_SC_NPROCESSORS_ONLN) asks. More than that only adds switching.

  • Work that waits: many more than the number of processors, because a waiting worker occupies

none. A program serving web requests may run hundreds.

Procedure

  1. Write the single-thread generator. Compile with gcc -Wall -Wextra -pthread -o fib1 fib1.c and

run it.

  1. Change N to 95 and note that the numbers go wrong: unsigned long overflows at F(93).
  2. Write the four-thread version. Change &job[i] to &i and see the answers become wrong, then

put it back.

  1. Write the unsafe counter and record the twenty-trial verdict.
  2. Add the mutex and run it ten times. Confirm the exact answer every time.
  3. Time both versions with time ./prog. The safe one is slower, and you should be able to say

why.

  1. Write the thread pool. Run it five times and confirm the output does not change.
  2. Move the fib_of call inside the mutex, run it again, and time it. Explain the difference.

Result

A Fibonacci sequence was generated by a thread and read by main with no mutex, the join being the only synchronisation needed. Four independent terms were computed by four threads writing into slots of their own. A shared counter updated by four threads without a mutex gave the right answer in 0 of 20 trials and lost up to three quarters of the updates; the same counter with a mutex gave the exact answer on all ten runs. A pool of three threads was given eight tasks, took them from a shared counter under a mutex, did the work outside the lock, and produced an identical result on every run, with main waiting on a condition variable.

munotes.in73

Practical 4: Multi-threading and Fibonacci Generation

Where marks are lost

  • Saying threads make the Fibonacci sequence faster. Each term needs the one before it. What

threads can do is compute separate terms at once.

  • Putting a mutex round the generator that does not need one, and not being able to say why

the join is enough.

  • Using int for Fibonacci numbers. F(47) does not fit.
  • Locking a mutex and returning without unlocking it. Every other thread then blocks for ever.
  • Doing the work inside the critical section, which makes a thread pool slower than a loop.
  • A busy-wait instead of a condition variable, or pthread_cond_wait inside an if instead

of a while.

  • Reading a shared variable without the lock, even just to test it.
  • Claiming a shared counter is safe because it printed the right answer once.
  • Forgetting -pthread.

For the journal

Write the aim, MU's own wording, the single-thread generator and its output, and the four-thread version with its four answers. Then the unsafe counter with the twenty-trial verdict, and the safe one with its exact answer, side by side: that pair is the evidence for the thread-safety bullet and it is what an examiner reads. Then the thread pool, its output, and three sentences: one counter hands out the work under a mutex, the work itself is done outside the mutex, and main waits on a condition variable rather than spinning. The conclusion: threads are safe when they touch separate data, and need a mutex the moment they touch the same variable.

Quick revision

  • F(0) is 0, F(1) is 1, F(n) is F(n-1) + F(n-2). Generating the sequence is sequential; computing

separate terms is parallel. Use unsigned long, which runs out at F(93).

  • Code is thread safe if it stays correct with several threads in it at once. Local variables are

safe for free; globals, statics and anything behind a shared pointer are not.

  • pthread_join is a synchronisation, and the cheapest one. Sharing needs no lock if the sharing

is not simultaneous.

  • The best thread safety is to divide the data: one slot per thread, added up at the end. That is

called a reduction.

  • A mutex is pthread_mutex_lock and pthread_mutex_unlock, with PTHREAD_MUTEX_INITIALIZER for

one that lives as long as the program.

  • A mutex has an owner and only the locking thread may unlock it; a binary semaphore has no owner

and can be signalled by anybody.

  • Rules: unlock on every path out, hold it briefly, use the same mutex for the same data, and take

several in the same order everywhere.

  • A thread pool creates a fixed number of threads and feeds them tasks. The task counter is under

a mutex; the work is done outside it.

munotes.in74

Practical 4: Multi-threading and Fibonacci Generation

  • A condition variable is how a thread waits for something to become true: always with a mutex,

always in a while loop, signalled with pthread_cond_signal or pthread_cond_broadcast.

  • Workers for computing: as many as there are processors. Workers for waiting: many more.

Questions you should be able to answer

1. Why can eight threads not generate eight Fibonacci numbers at once? Because each term is the sum of the two before it, so the sequence has to be built in order. Separate terms computed from scratch are independent and can be done in parallel.

2. The generator program shares an array between a thread and main and has no mutex. Why is it correct? Because pthread_join guarantees the thread has finished before main reads the array, so the two never touch it at the same time. It is simultaneous access that needs protecting, not sharing.

3. Four threads add one to a shared counter two thousand times each. What is printed, and why? Some number between 2000 and 8000. Each increment is a read, an add and a write, and a thread switched out between the read and the write overwrites another thread's update. On the machine this page was checked on, twenty trials gave the right answer zero times and the worst was 2000.

4. What is a mutex, and how does it differ from a binary semaphore? A lock that one thread holds at a time. A mutex has an owner, so only the thread that locked it may unlock it; a binary semaphore has no owner and any thread may signal it.

5. Why is the mutex version of the counter slower than the broken one? Because every increment now happens one thread at a time. A counter every thread must update cannot be parallelised, only made correct. Giving each thread its own total and adding them up at the end avoids the lock entirely.

6. What is a thread pool, and why use one instead of a thread per task? A fixed set of threads that take tasks from a shared list. Creating a thread costs time, and far more threads than processors spend their time switching rather than working.

7. In the pool, why is fib_of called outside the mutex? Because everything inside a mutex happens one thread at a time. With the work inside, three workers would do one worker's throughput and the pool would be pointless.

8. What is a condition variable and what are the two rules for using one? A place a thread waits for something to become true. It is always used with a mutex, which it releases while waiting, and the wait is always inside a while loop testing the condition, because a thread can be woken while the condition is still false.

munotes.in75

Practical 4: Multi-threading and Fibonacci Generation

9. How many workers should a pool have? About as many as the machine has processors for work that computes, and many more than that for work that waits, because a waiting thread occupies no processor.

Contents This chapter on its own page

munotes.in76

Chapter Ten

Practical 5: Process Synchronisation and the Bounded Buffer

Syllabus topic Module 1, "Process Synchronization and Bounded Buffer Problem: Simulate producer-consumer bounded buffer using mutex and semaphores. Implement buffer control with synchronized access. Introduce circular queue techniques for managing shared buffers."

Aim

To implement the producer-consumer problem over a bounded buffer using a mutex and semaphores, to manage the buffer as a circular queue, and to see what a wrong order of waits does.

What you need to know before you start

[Practical 1 continued: the Race Condition, Semaphores, and Producer and Consumer] solved producer and consumer with one slot. The two processes then ran in lock-step: the producer made one item, stopped, waited for the consumer to take it, and only then made the next. Correct, and half the speed of either side on its own.

A bounded buffer is the same problem with room for several items. The producer can run ahead while the consumer is busy, and the consumer can keep working while the producer is thinking. It is the standard shape of almost every piece of software that has a fast part and a slow part: a print queue, a video player's frame buffer, a web server's list of waiting requests.

Three rules have to hold at every instant, and each needs its own mechanism:

RuleWhat enforces it
The producer must not write when the buffer is fulla counting semaphore, empty
The consumer must not read when the buffer is emptya counting semaphore, full
Two threads must not change the buffer's indices at the same timea mutex

That is the answer to "why does the bounded buffer need three things when the one-slot version needed two". With one slot the two semaphores already made simultaneous access impossible. With several slots a producer and a consumer can legitimately be inside the buffer at the same moment, at different positions, and the indices are then the shared variable that races.

The three semaphores, in one table

Starts atCountsWaited on bySignalled by
emptySIZEfree slotsthe producer, before writingthe consumer, after reading
full0filled slotsthe consumer, before readingthe producer, after writing
mutex1the right to touch the indicesbothboth

Read the first two rows as a sentence, as in Practical 1: the producer consumes a free slot and produces a full one; the consumer does the reverse. The two semaphores always add up to SIZE plus however many threads are inside the buffer at that moment.

POSIX semaphores, which are what threads use

Practical 1 used System V semaphores, because it was synchronising two separate processes. Within one process the POSIX ones are far simpler, and they are what this chapter uses.

CallIn words
sem_init(&s, 0, n)set up a semaphore with starting value n; the 0 means "threads of this process only"
sem_wait(&s)the P operation: decrement, blocking at 0
sem_post(&s)the V operation: increment, waking a waiter
sem_trywait(&s)decrement if you can, and fail with EAGAIN if not
sem_getvalue(&s, &v)read the current value, for printing
sem_destroy(&s)finished with it
munotes.in77

Practical 5: Process Synchronisation and the Bounded Buffer

They need #include <semaphore.h> and no union semun, nothing survives the program, and there is nothing to clean up with ipcrm. The middle argument of sem_init is a flag: 0 for threads of one process, non-zero for a semaphore in shared memory that other processes can see.

The circular queue, which is how a bounded buffer is managed

MU's third bullet. A bounded buffer is an array of fixed size used as a queue, and the trick is how the two ends move.

An ordinary array queue takes items at the front and adds them at the back, and after a while both indices have walked off the end even though the array is nearly empty. A circular queue brings them back round with one operator:

in  = (in  + 1) % SIZE;      /* the producer's index */
out = (out + 1) % SIZE;      /* the consumer's index */

% SIZE is the whole idea. With SIZE of 4, in runs 0, 1, 2, 3, 0, 1, 2, 3 for ever, and the array is reused round and round with no copying and no wasted space.

Here is a buffer of four slots with two items in it, and the two indices in their places:

index0123
holds101102freefree
outyes
inyes

out points at the next item to be taken. in points at the next free slot to be filled. When in reaches 3 and the producer fills it, (3 + 1) % 4 is 0 and in is back at the beginning; by then slot 0 has long since been emptied.

The indices alone cannot tell you whether the buffer is full or empty. When in equals out, the buffer is either completely empty or completely full and the two cases look identical. There are three standard cures, and this chapter uses the first because MU asks for semaphores:

  1. Let the semaphores count. empty and full know how many slots are which, so the indices

never have to be compared at all. This is the cleanest answer.

  1. Keep a separate count of items. One more variable, incremented and decremented under the

mutex.

  1. Leave one slot always empty, so full is (in + 1) % SIZE == out. This wastes one slot and

is what a queue with no semaphores does. It comes back in [Practical 16: Queues and Circular Queues], where it is the standard answer.

The program: two producers, three consumers, four slots

#include <stdio.h>
#include <pthread.h>
#include <semaphore.h>

#define SIZE      4
#define PRODUCERS 2
#define CONSUMERS 3
#define PER       10                   /* items each producer makes */

static int  buffer[SIZE];
static int  in, out;                   /* the circular queue's two indices */
static long produced_total, consumed_total;

static sem_t empty, full;
static pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;

static void *producer(void *arg)
{
    long id = (long) arg;

    for (int i = 1; i <= PER; i++) {
        int item = (int) (id * 100 + i);      /* 101..110, then 201..210 */

        sem_wait(&empty);                     /* 1. wait for a free slot */
        pthread_mutex_lock(&lock);            /* 2. then take the lock */

        buffer[in] = item;
        in = (in + 1) % SIZE;
        produced_total = produced_total + item;

        pthread_mutex_unlock(&lock);          /* release in the opposite order */
        sem_post(&full);                      /* one more filled slot */
    }
    return NULL;
}

static void *consumer(void *arg)
{
    long want = (long) arg;                   /* how many this one must take */

    for (long i = 0; i < want; i++) {
        sem_wait(&full);                      /* 1. wait for a filled slot */
        pthread_mutex_lock(&lock);            /* 2. then take the lock */

        int item = buffer[out];
        out = (out + 1) % SIZE;
        consumed_total = consumed_total + item;

        pthread_mutex_unlock(&lock);
        sem_post(&empty);                     /* one more free slot */
    }
    return NULL;
}

int main(void)
{
    if (sem_init(&empty, 0, SIZE) == -1 || sem_init(&full, 0, 0) == -1) {
        perror("sem_init");
        return 1;
    }

    pthread_t p[PRODUCERS], c[CONSUMERS];
    long total = (long) PRODUCERS * PER;

    /* the work is shared out so that the consumers between them take
       exactly as many items as the producers make, and nobody is left
       waiting at the end for an item that will never come */
    long share = total / CONSUMERS;
    long extra = total % CONSUMERS;

    for (long i = 0; i < PRODUCERS; i++)
        if (pthread_create(&p[i], NULL, producer, (void *) (i + 1)) != 0)
            return 1;

    for (long i = 0; i < CONSUMERS; i++) {
        long want = share + (i < extra ? 1 : 0);
        if (pthread_create(&c[i], NULL, consumer, (void *) want) != 0)
            return 1;
    }

    for (int i = 0; i < PRODUCERS; i++)
        pthread_join(p[i], NULL);
    for (int i = 0; i < CONSUMERS; i++)
        pthread_join(c[i], NULL);

    printf("items produced by %d producers : %ld\n", PRODUCERS, total);
    printf("the sum of everything produced : %ld\n", produced_total);
    printf("the sum of everything consumed : %ld\n", consumed_total);
    printf("nothing lost, nothing doubled  : %s\n",
           produced_total == consumed_total ? "yes" : "NO");

    sem_destroy(&empty);
    sem_destroy(&full);
    return 0;
}
munotes.in78

Practical 5: Process Synchronisation and the Bounded Buffer

items produced by 2 producers : 20
the sum of everything produced : 3110
the sum of everything consumed : 3110
nothing lost, nothing doubled  : yes

Why the totals are the proof

Twenty items went through a buffer of four slots, driven by five threads, and the two sums are equal on every run.

munotes.in79

Practical 5: Process Synchronisation and the Bounded Buffer

The numbers are chosen so that the sum is worth checking by hand. Producer 1 makes 101 to 110, which adds up to 1055; producer 2 makes 201 to 210, which adds up to 2055; 1055 plus 2055 is 3110. Each item is distinct, so an item consumed twice or missed altogether would change the consumed total, and a wrong index would read a slot that had not been written and add the wrong number. The equality of the two sums is a much stronger check than printing the items, because the items come out in a different order every run and their sum does not.

That is the general lesson about testing concurrent programs: find a quantity that is invariant whatever the scheduling does, and check that.

The order of the two waits

Look at the producer again:

sem_wait(&empty);            /* first the resource  */
pthread_mutex_lock(&lock);   /* then the lock       */
   ...
pthread_mutex_unlock(&lock);
sem_post(&full);             /* release in the opposite order */

The resource first, the lock second, and release in the opposite order. That rule was stated at the end of Practical 1. The next section shows what breaking it costs, by running it.

The deadlock, run

The same program with the two waits swapped in both threads. Nothing else is different.

#include <stdio.h>
#include <pthread.h>
#include <semaphore.h>

#define SIZE 2

static int  buffer[SIZE];
static int  in, out;
static sem_t empty, full;
static pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;

static void *producer(void *arg)
{
    (void) arg;
    for (int i = 1; i <= 10; i++) {
        pthread_mutex_lock(&lock);       /* THE WRONG WAY ROUND */
        sem_wait(&empty);
        buffer[in] = i;
        in = (in + 1) % SIZE;
        sem_post(&full);
        pthread_mutex_unlock(&lock);
    }
    return NULL;
}

static void *consumer(void *arg)
{
    (void) arg;
    for (int i = 1; i <= 10; i++) {
        pthread_mutex_lock(&lock);       /* THE WRONG WAY ROUND */
        sem_wait(&full);
        (void) buffer[out];
        out = (out + 1) % SIZE;
        sem_post(&empty);
        pthread_mutex_unlock(&lock);
    }
    return NULL;
}

int main(void)
{
    sem_init(&empty, 0, SIZE);
    sem_init(&full, 0, 0);

    pthread_t p, c;
    pthread_create(&p, NULL, producer, NULL);
    pthread_create(&c, NULL, consumer, NULL);
    pthread_join(p, NULL);
    pthread_join(c, NULL);

    printf("this line is never reached\n");
    return 0;
}

That program prints nothing and never ends. Run it with a stopwatch and it will still be there:

$ gcc -Wall -Wextra -pthread -o deadlock deadlock.c
$ timeout 3 ./deadlock
$ echo $?
124

timeout returns 124 when it has to kill the program, and that is what the checker for this book demanded: the listing is marked as a program that never finishes, and a version that finished would have been reported as a failure. The deadlock on this page is a measured fact, not a prediction.

munotes.in80

Practical 5: Process Synchronisation and the Bounded Buffer

Why it deadlocks

The buffer holds two slots, so after two items the empty semaphore is 0. Then:

StepThe producerThe consumer
1takes the mutex
2waits on empty, which is 0, and blocks
3wants the mutex, which the producer holds, and blocks
4still waiting for a slot the consumer would freestill waiting for a lock the producer will not release

Each holds what the other needs, and neither can move. That is a deadlock, and it is the exact picture of the four conditions Coffman set out, all of which must hold at once for one to be possible:

ConditionHere
Mutual exclusion: a resource is held by one thread at a timethe mutex
Hold and wait: a thread holding one resource waits for anotherthe producer holds the mutex and waits for empty
No preemption: a resource cannot be taken awaynothing can force the mutex out of the producer's hand
Circular wait: a cycle of threads each waiting for the nextproducer waits for the consumer, consumer waits for the producer

Break any one of the four and a deadlock is impossible. Putting sem_wait(&empty) before pthread_mutex_lock breaks hold and wait: the producer waits for the slot while holding nothing, so the consumer can always get the mutex and free a slot. That is why the rule is the rule.

This program has a second symptom, and it is worth noticing as well: it also holds the mutex while blocked, so the whole buffer is locked by a thread that is asleep. Even without the deadlock, never block while holding a lock is a rule in its own right.

Watching the buffer fill and empty

The last program prints the state after each operation, so the circular queue can be seen going round. One producer, one consumer, the consumer deliberately slower, and the buffer's occupancy read out of the semaphores.

#include <stdio.h>
#include <time.h>
#include <pthread.h>
#include <semaphore.h>

#define SIZE  3
#define ITEMS 6

static int buffer[SIZE];
static int in, out;
static sem_t empty, full;
static pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;

static void nap(long millis)
{
    struct timespec t = { millis / 1000, (millis % 1000) * 1000000L };
    nanosleep(&t, NULL);
}

static void *producer(void *arg)
{
    (void) arg;
    for (int i = 1; i <= ITEMS; i++) {
        sem_wait(&empty);
        pthread_mutex_lock(&lock);
        buffer[in] = i * 11;
        printf("producer: put %2d at index %d\n", buffer[in], in);
        fflush(stdout);
        in = (in + 1) % SIZE;
        pthread_mutex_unlock(&lock);
        sem_post(&full);
        nap(20);
    }
    return NULL;
}

static void *consumer(void *arg)
{
    (void) arg;
    for (int i = 1; i <= ITEMS; i++) {
        sem_wait(&full);
        pthread_mutex_lock(&lock);
        printf("consumer: took %2d from index %d\n", buffer[out], out);
        fflush(stdout);
        out = (out + 1) % SIZE;
        pthread_mutex_unlock(&lock);
        sem_post(&empty);
        nap(60);                        /* three times slower than the producer */
    }
    return NULL;
}

int main(void)
{
    sem_init(&empty, 0, SIZE);
    sem_init(&full, 0, 0);

    pthread_t p, c;
    pthread_create(&p, NULL, producer, NULL);
    pthread_create(&c, NULL, consumer, NULL);
    pthread_join(p, NULL);
    pthread_join(c, NULL);

    printf("both finished, in is %d and out is %d\n", in, out);
    sem_destroy(&empty);
    sem_destroy(&full);
    return 0;
}
munotes.in81

Practical 5: Process Synchronisation and the Bounded Buffer

producer: put 11 at index 0
consumer: took 11 from index 0
producer: put 22 at index 1
producer: put 33 at index 2
producer: put 44 at index 0
consumer: took 22 from index 1
producer: put 55 at index 1
consumer: took 33 from index 2
producer: put 66 at index 2
consumer: took 44 from index 0
consumer: took 55 from index 1
consumer: took 66 from index 2
both finished, in is 0 and out is 0

Three things in that run, and all three are the point of the exercise.

The indices wrap. Index 0 is used twice, once for item 11 and again for item 44, because (2 + 1) % 3 is 0. The array of three slots carried six items.

The producer got ahead and then had to stop. It put 11, 22, 33 and 44 in before the consumer had taken much, and then it had to wait: with three slots and three items outstanding, empty was

  1. That is buffer control working.

Both indices end at 0. Six items through a buffer of three slots means each index went round exactly twice, so both are back where they started. That is a small invariant worth checking in your own run: after N items through a buffer of size S, both indices are at N modulo S.

The interleaving of the lines is not the same on every run, so the checker compares them as a set; the line above is a real run. What is fixed, and what the program guarantees, is that each item is put exactly once and taken exactly once, and that no took line ever names a slot that has not been filled since it was last emptied.

Procedure

  1. Write the first program. Compile with gcc -Wall -Wextra -pthread -o bb bb.c and run it five

times. Confirm the two sums are equal each time.

  1. Comment out the pthread_mutex_lock and pthread_mutex_unlock lines and run it twenty times.

Note how often the two sums differ.

  1. Put the mutex back. Change SIZE to 1 and confirm it still works but that the threads now

alternate strictly.

  1. Write the deadlock version. Run it with timeout 3 ./deadlock; echo $? and record the 124.
  2. Draw the four Coffman conditions against that program in your journal.
  3. Write the watching program. Follow the indices round and check that both end at ITEMS modulo
munotes.in82

Practical 5: Process Synchronisation and the Bounded Buffer

SIZE.

  1. Make the producer the slower of the two instead and run it again. The buffer now stays nearly

empty, and the consumer waits.

Result

A bounded buffer of four slots, managed as a circular queue with the modulo operator, was driven by two producer threads and three consumer threads using two counting semaphores and one mutex. Twenty distinct items were passed through it and the sum produced equalled the sum consumed on every run, which proves nothing was lost or duplicated. Swapping the semaphore wait with the mutex lock produced a deadlock, confirmed by the program being killed by a three-second timeout with status 124.

Where marks are lost

  • Only two semaphores and no mutex. With more than one slot the two indices are a shared

variable and they race. The one-slot version in Practical 1 is the only case where two are enough.

  • Taking the mutex before waiting on the semaphore. The deadlock above, and it is a guaranteed

loss of marks because the examiner's program hangs.

  • Not releasing in the opposite order. Signal the semaphore after unlocking, not before.
  • sem_init(&empty, 0, 0). The empty semaphore starts at SIZE, not at 0. This is the

commonest arithmetic mistake in the exercise and it makes the producer block immediately.

  • Forgetting % SIZE. The index walks off the end of the array, which is a segmentation fault

or, worse, silent corruption.

  • Comparing in with out to test for full or empty while also using semaphores. Pick one

mechanism; the semaphores already know.

  • Consumers waiting for items that will never come. If the consumers between them expect more

items than the producers make, the extra consumers block for ever and the program never ends. The share-out in main is there for that reason.

  • Doing input or output inside the critical section in a real program. It is done in the

watching program above only so that the reader can see the order, and it is noted as a deliberate exception.

For the journal

Write the aim, MU's own wording, the table of the three semaphores with their starting values, and the first program in full with its output. Write the two sums out and add the hand calculation that 1055 plus 2055 is 3110, because that is what shows you checked. Then the deadlock version, the timeout 3 command, the 124, and the four Coffman conditions written against it. The conclusion: a bounded buffer needs two counting semaphores for the slots and one mutex for the indices, the resource must be waited for before the lock is taken, and the circular queue is what lets a fixed array be reused for ever.

munotes.in83

Practical 5: Process Synchronisation and the Bounded Buffer

Quick revision

  • A bounded buffer lets the producer run ahead of the consumer by up to SIZE items. One slot is

the special case where the two must alternate.

  • Three mechanisms: empty starting at SIZE counts free slots, full starting at 0 counts filled

slots, and a mutex protects the two indices.

  • The mutex is needed as soon as there is more than one slot, because two threads may then be

inside the buffer at once at different positions.

  • POSIX semaphores for threads: sem_init, sem_wait, sem_post, sem_destroy, in

semaphore.h. No union semun, and nothing to remove with ipcrm.

  • A circular queue is in = (in + 1) % SIZE. The indices alone cannot distinguish full from

empty; let the semaphores count, or keep a separate count, or leave one slot free.

  • The order is: wait for the resource, then take the lock, then release the lock, then signal.
  • Swap the first two and the program deadlocks: the producer holds the mutex while waiting for a

slot that only the consumer can free, and the consumer needs the mutex.

  • Coffman's four conditions, all needed at once: mutual exclusion, hold and wait, no preemption,

circular wait. Waiting for the resource before the lock breaks hold and wait.

  • Never block while holding a lock.
  • Test a concurrent program by an invariant that the scheduling cannot change, such as the total

produced against the total consumed.

Questions you should be able to answer

1. Why does a bounded buffer need a mutex when the one-slot version did not? Because with several slots a producer and a consumer can legitimately be inside the buffer at the same time at different positions, and then the in and out indices are shared variables that race. With one slot the two semaphores already made simultaneous access impossible.

2. What does each of the three semaphores count, and what does each start at? empty counts free slots and starts at SIZE; full counts filled slots and starts at 0; the mutex grants the right to touch the indices and starts at 1.

3. What is the correct order of the two waits in the producer, and why? Wait on empty first, then take the mutex. A producer that holds the mutex while waiting for a slot prevents the consumer from freeing one, which is a deadlock.

4. State the four conditions for a deadlock and point to each of them in the broken program. Mutual exclusion, which is the mutex; hold and wait, which is the producer holding the mutex while waiting on empty; no preemption, since nothing can take the mutex away; and circular wait, since the producer waits for the consumer and the consumer for the producer.

munotes.in84

Practical 5: Process Synchronisation and the Bounded Buffer

5. How does the circular queue reuse the array, and what does the modulo do? in = (in + 1) % SIZE brings the index back to 0 after the last slot, so the array is used round and round with no copying. With SIZE of 3, the indices run 0, 1, 2, 0, 1, 2.

6. When in equals out, is the buffer full or empty? It cannot be told from the indices alone. The semaphores know, or a separate count of items can be kept, or one slot can be left permanently empty so that full is (in + 1) % SIZE == out.

7. How do you know that no item was lost or consumed twice? Because each item is a distinct number and the sum of what was produced equals the sum of what was consumed. The order changes on every run and the sum does not, which is what makes it a usable test.

8. Six items pass through a buffer of three slots. Where do the indices end up? Both at 0. Six modulo three is nought, so each index has gone round exactly twice.

Contents This chapter on its own page

munotes.in85

Chapter Eleven

Practical 6: the Readers-Writers Problem

Syllabus topic Module 1, "Readers-Writers Problem, Synchronization in Shared Access: Implement reader and writer prioritization. Use semaphores to allow multiple readers or exclusive writer access. Extend to fairness in access and deadlock prevention."

Aim

To let several readers share data while a writer has it exclusively, using semaphores; to implement reader and writer prioritisation; and to make access fair while preventing deadlock.

What you need to know before you start

A mutex treats every thread the same: one in at a time. For a great deal of real data that is needlessly strict, because reading does not change anything. Twenty threads may read the same table at once with no harm at all. It is only writing that has to be alone.

The readers-writers problem is that rule made precise:

Who is insideAllowed
any number of readers, no writeryes
one writer, no readersyes
a writer and any readerno
two writersno

The purpose is easy to see in a college database: hundreds of students looking up a result at once is fine; one clerk changing a result must have the record to themselves, or a student may read a mark that is half updated.

What "half updated" means, and how this chapter proves it

Every program in this chapter shares a record of two fields that must agree:

struct record { int value; int doubled; };

The writer sets value to n and doubled to 2n. Between those two assignments the record is inconsistent, and a reader that looks at that moment sees a doubled that is not twice the value. Each program counts how many readers saw such a record, and prints the count.

That is the measurement this chapter rests on. A right answer is 0 and a wrong one is not, and neither depends on the order the threads happened to run in.

The first solution: readers preferred

This is the classical answer and the one an examiner expects first. One semaphore guards the data, and the readers take it as a group: the first reader in locks it, the last reader out unlocks it, and the readers in between simply walk in.

#include <stdio.h>
#include <time.h>
#include <pthread.h>
#include <semaphore.h>

#define READERS 4
#define WRITERS 2
#define ROUNDS  4

struct record { int value; int doubled; };

static struct record shared = { 0, 0 };
static int readers_inside;               /* how many readers are in right now */
static int max_readers;                  /* the most ever in at once */
static int torn_reads;                   /* readers that saw a half-written record */

static sem_t resource;                   /* one writer, or the readers as a group */
static pthread_mutex_t count_lock = PTHREAD_MUTEX_INITIALIZER;  /* guards readers_inside */
static pthread_mutex_t tally = PTHREAD_MUTEX_INITIALIZER;       /* guards torn_reads */

static void nap(long ms)
{
    struct timespec t = { ms / 1000, (ms % 1000) * 1000000L };
    nanosleep(&t, NULL);
}

static void *reader(void *arg)
{
    (void) arg;
    for (int r = 0; r < ROUNDS; r++) {
        /* entry protocol */
        pthread_mutex_lock(&count_lock);
        if (++readers_inside == 1)
            sem_wait(&resource);         /* the FIRST reader locks writers out */
        if (readers_inside > max_readers)
            max_readers = readers_inside;
        pthread_mutex_unlock(&count_lock);

        /* the reading itself: several readers may be here at once */
        int v = shared.value;
        nap(5);
        int d = shared.doubled;
        if (d != 2 * v) {
            pthread_mutex_lock(&tally);
            torn_reads++;
            pthread_mutex_unlock(&tally);
        }

        /* exit protocol */
        pthread_mutex_lock(&count_lock);
        if (--readers_inside == 0)
            sem_post(&resource);         /* the LAST reader lets writers in */
        pthread_mutex_unlock(&count_lock);

        nap(3);
    }
    return NULL;
}

static void *writer(void *arg)
{
    (void) arg;
    for (int r = 0; r < ROUNDS; r++) {
        sem_wait(&resource);             /* a writer needs it all to itself */

        int n = shared.value + 1;
        shared.value = n;
        nap(5);                          /* the record is inconsistent here */
        shared.doubled = 2 * n;

        sem_post(&resource);
        nap(3);
    }
    return NULL;
}

int main(void)
{
    if (sem_init(&resource, 0, 1) == -1) {
        perror("sem_init");
        return 1;
    }

    pthread_t rd[READERS], wr[WRITERS];
    for (long i = 0; i < READERS; i++)
        if (pthread_create(&rd[i], NULL, reader, (void *) (i + 1)) != 0)
            return 1;
    for (long i = 0; i < WRITERS; i++)
        if (pthread_create(&wr[i], NULL, writer, (void *) (i + 1)) != 0)
            return 1;

    for (int i = 0; i < READERS; i++)
        pthread_join(rd[i], NULL);
    for (int i = 0; i < WRITERS; i++)
        pthread_join(wr[i], NULL);

    printf("writes made, %d writers x %d rounds : %d\n", WRITERS, ROUNDS, shared.value);
    printf("the record is still consistent      : %s\n",
           shared.doubled == 2 * shared.value ? "yes" : "NO");
    printf("readers that saw a half-written one : %d\n", torn_reads);
    printf("more than one reader inside at once : %s\n",
           max_readers > 1 ? "yes" : "NO");

    sem_destroy(&resource);
    return 0;
}
munotes.in86

Practical 6: the Readers-Writers Problem

writes made, 2 writers x 4 rounds : 8
the record is still consistent      : yes
readers that saw a half-written one : 0
more than one reader inside at once : yes

Four lines, and every one of them is a claim the program checked.

  • 8 writes from two writers of four rounds each, so no write was lost: the two writers were

never inside together.

  • The record is consistent at the end, and no reader ever saw a half-written one, so no

reader was ever inside while a writer was.

  • Four readers were inside at once, which is the whole point. A plain mutex would have made

that number 1, and the program would have been correct and needlessly slow.

That last number is what tells you the solution is a readers-writers solution and not just a lock. It is worth printing in your own program for exactly that reason.

munotes.in87

Practical 6: the Readers-Writers Problem

The three pieces of that solution

resource, a binary semaphore, is the right to touch the data. A writer takes it for itself. The readers take it collectively.

readers_inside, a counter, is how the readers know whether they are the first or the last. It is shared, so it needs its own lock.

count_lock, a mutex, guards that counter. Without it, two readers arriving together could both see readers_inside go from 0 to 1 and both call sem_wait(&resource), and the second one would block for ever on a semaphore the first one is holding.

Notice that sem_wait(&resource) is called while holding count_lock, which contradicts the rule from Practical 5 about not blocking while holding a lock. It is safe here, and knowing why is worth a mark: the only thread that can be holding resource is a writer, and a writer never asks for count_lock, so there is no cycle. The four Coffman conditions need a circular wait and there is none. That is the difference between a rule of thumb and a proof.

Writer starvation, run

The solution above has a defect, and it is not a bug: it does exactly what it was designed to do, and what it was designed to do is unfair.

resource is released only when the last reader leaves. If readers keep arriving, there is never a moment with no reader inside, so the writer waits for ever. That is starvation: not a deadlock, because everybody else is making progress, but one thread that never gets its turn.

Here is that made concrete. Two readers, staggered so that one is always inside, and a writer that asks once.

#include <stdio.h>
#include <time.h>
#include <pthread.h>
#include <semaphore.h>

static int  readers_inside;
static long reads_done;
static sem_t resource;
static pthread_mutex_t count_lock = PTHREAD_MUTEX_INITIALIZER;

static void nap(long ms)
{
    struct timespec t = { ms / 1000, (ms % 1000) * 1000000L };
    nanosleep(&t, NULL);
}

static void *reader(void *arg)
{
    long stagger = (long) arg;
    nap(stagger);                        /* so the two readers overlap */

    for (;;) {
        pthread_mutex_lock(&count_lock);
        if (++readers_inside == 1)
            sem_wait(&resource);
        pthread_mutex_unlock(&count_lock);

        nap(40);                         /* a long read */

        pthread_mutex_lock(&count_lock);
        reads_done++;
        if (--readers_inside == 0)
            sem_post(&resource);
        pthread_mutex_unlock(&count_lock);
        /* and straight back round: no pause at all */
    }
    return NULL;
}

static void *writer(void *arg)
{
    (void) arg;
    sem_wait(&resource);                 /* the writer waits here for ever */
    printf("the writer got in after %ld reads\n", reads_done);
    sem_post(&resource);
    return NULL;
}

int main(void)
{
    sem_init(&resource, 0, 1);

    pthread_t r1, r2, w;
    pthread_create(&r1, NULL, reader, (void *) 0L);
    pthread_create(&r2, NULL, reader, (void *) 20L);
    nap(100);                            /* let the readers get going */
    pthread_create(&w, NULL, writer, NULL);

    pthread_join(w, NULL);
    printf("main: finished\n");
    return 0;
}
munotes.in88

Practical 6: the Readers-Writers Problem

That program prints nothing at all, ever. The two readers overlap, readers_inside never reaches 0, resource is never released, and the writer sits in sem_wait until the machine is switched off:

$ gcc -Wall -Wextra -pthread -o starve starve.c
$ timeout 5 ./starve
$ echo $?
124

Three runs, three timeouts. The listing is marked in this book as a program that never finishes, so a version of it that did get the writer in would have been reported as a failure. The starvation is measured, not asserted.

Starvation is not deadlock, and an examiner will ask for the difference:

DeadlockStarvation
Who is stuckevery thread in the cycleone thread, or one kind of thread
Is anything getting doneno, the system is frozenyes, the others are working normally
Causea circular wait for resourcesa scheduling or priority rule that keeps skipping somebody
Detectable bylooking for a cyclenoticing that somebody has waited a very long time
Cured bybreaking one of Coffman's four conditionsa fairness rule, such as a queue everybody joins

The second solution: writers preferred

The mirror image. A writer that is waiting stops new readers from going in, so the readers already inside finish and then the writer gets its turn.

It is built by counting the waiting writers as well, and giving them a semaphore of their own:

/* the writer's entry protocol */
lock(writer_count_lock);
if (++writers_waiting == 1) wait(readers_may_enter);   /* shut the door on readers */
unlock(writer_count_lock);
wait(resource);

Now the writers do not starve. The readers do: a steady stream of writers keeps readers_may_enter locked and no reader ever gets in. The problem has simply moved.

That is the honest position, and it is the answer to MU's bullet about prioritisation: neither priority is fair, and choosing one is choosing whose starvation you can live with.

SolutionGood forStarves
Readers preferreddata read far more often than writtenwriters
Writers preferreddata that must never be stale, such as a booking systemreaders
Fair, with a queuealmost everythingnobody

The third solution: fair, with a turnstile

The fix is a single extra semaphore that everybody must pass through before joining the readers or the writers. A writer holds it while it waits, so readers arriving after the writer queue behind it; the readers already inside finish, the writer goes in, and then the queue moves again.

It is called a turnstile, and it is four lines of difference from the first program.

#include <stdio.h>
#include <time.h>
#include <pthread.h>
#include <semaphore.h>

#define READERS 4
#define WRITERS 2
#define ROUNDS  4

struct record { int value; int doubled; };

static struct record shared = { 0, 0 };
static int readers_inside, max_readers, torn_reads;

static sem_t resource;                   /* one writer, or the readers as a group */
static sem_t turnstile;                  /* everybody queues here first */
static pthread_mutex_t count_lock = PTHREAD_MUTEX_INITIALIZER;
static pthread_mutex_t tally = PTHREAD_MUTEX_INITIALIZER;

static void nap(long ms)
{
    struct timespec t = { ms / 1000, (ms % 1000) * 1000000L };
    nanosleep(&t, NULL);
}

static void *reader(void *arg)
{
    (void) arg;
    for (int r = 0; r < ROUNDS; r++) {
        sem_wait(&turnstile);            /* queue, so a waiting writer is not overtaken */
        sem_post(&turnstile);            /* and let the next one queue at once */

        pthread_mutex_lock(&count_lock);
        if (++readers_inside == 1)
            sem_wait(&resource);
        if (readers_inside > max_readers)
            max_readers = readers_inside;
        pthread_mutex_unlock(&count_lock);

        int v = shared.value;
        nap(5);
        int d = shared.doubled;
        if (d != 2 * v) {
            pthread_mutex_lock(&tally);
            torn_reads++;
            pthread_mutex_unlock(&tally);
        }

        pthread_mutex_lock(&count_lock);
        if (--readers_inside == 0)
            sem_post(&resource);
        pthread_mutex_unlock(&count_lock);

        nap(3);
    }
    return NULL;
}

static void *writer(void *arg)
{
    (void) arg;
    for (int r = 0; r < ROUNDS; r++) {
        sem_wait(&turnstile);            /* HOLD it: no new reader may join */
        sem_wait(&resource);             /* wait for the readers inside to leave */

        int n = shared.value + 1;
        shared.value = n;
        nap(5);
        shared.doubled = 2 * n;

        sem_post(&resource);
        sem_post(&turnstile);            /* let everybody through again */

        nap(3);
    }
    return NULL;
}

int main(void)
{
    sem_init(&resource, 0, 1);
    sem_init(&turnstile, 0, 1);

    pthread_t rd[READERS], wr[WRITERS];
    for (long i = 0; i < READERS; i++)
        if (pthread_create(&rd[i], NULL, reader, (void *) (i + 1)) != 0)
            return 1;
    for (long i = 0; i < WRITERS; i++)
        if (pthread_create(&wr[i], NULL, writer, (void *) (i + 1)) != 0)
            return 1;

    for (int i = 0; i < READERS; i++)
        pthread_join(rd[i], NULL);
    for (int i = 0; i < WRITERS; i++)
        pthread_join(wr[i], NULL);

    printf("writes made, %d writers x %d rounds : %d\n", WRITERS, ROUNDS, shared.value);
    printf("the record is still consistent      : %s\n",
           shared.doubled == 2 * shared.value ? "yes" : "NO");
    printf("readers that saw a half-written one : %d\n", torn_reads);
    printf("more than one reader inside at once : %s\n",
           max_readers > 1 ? "yes" : "NO");

    sem_destroy(&resource);
    sem_destroy(&turnstile);
    return 0;
}
munotes.in89

Practical 6: the Readers-Writers Problem

writes made, 2 writers x 4 rounds : 8
the record is still consistent      : yes
readers that saw a half-written one : 0
more than one reader inside at once : yes

Exactly the same four answers, and readers still get in together, so nothing was given up to buy the fairness. The readers still share; they merely queue politely.

And the starvation is gone

The proof is the starving program from earlier with the turnstile added, under exactly the same load of readers that never stop.

#include <stdio.h>
#include <time.h>
#include <pthread.h>
#include <semaphore.h>

static int  readers_inside;
static long reads_done;
static sem_t resource;
static sem_t turnstile;                  /* the only difference */
static pthread_mutex_t count_lock = PTHREAD_MUTEX_INITIALIZER;

static void nap(long ms)
{
    struct timespec t = { ms / 1000, (ms % 1000) * 1000000L };
    nanosleep(&t, NULL);
}

static void *reader(void *arg)
{
    long stagger = (long) arg;
    nap(stagger);

    for (;;) {
        sem_wait(&turnstile);
        sem_post(&turnstile);

        pthread_mutex_lock(&count_lock);
        if (++readers_inside == 1)
            sem_wait(&resource);
        pthread_mutex_unlock(&count_lock);

        nap(40);

        pthread_mutex_lock(&count_lock);
        reads_done++;
        if (--readers_inside == 0)
            sem_post(&resource);
        pthread_mutex_unlock(&count_lock);
    }
    return NULL;
}

static void *writer(void *arg)
{
    (void) arg;
    sem_wait(&turnstile);                /* holding this stops new readers */
    sem_wait(&resource);
    printf("the writer got in\n");
    sem_post(&resource);
    sem_post(&turnstile);
    return NULL;
}

int main(void)
{
    sem_init(&resource, 0, 1);
    sem_init(&turnstile, 0, 1);

    pthread_t r1, r2, w;
    pthread_create(&r1, NULL, reader, (void *) 0L);
    pthread_create(&r2, NULL, reader, (void *) 20L);
    nap(100);
    pthread_create(&w, NULL, writer, NULL);

    pthread_join(w, NULL);
    printf("main: finished\n");
    return 0;
}
munotes.in90

Practical 6: the Readers-Writers Problem

the writer got in
main: finished

The writer gets in, on every run. The readers are still going round with no pause at all; the turnstile is what lets the writer put itself at the head of the queue.

Those two programs are the answer to MU's bullet about fairness. They differ by four lines: two in the reader and two in the writer. One never lets the writer in, and the other always does.

Deadlock prevention here

MU's third bullet ends with deadlock prevention, and in this exercise it comes down to one rule about the order the semaphores are taken.

The writer takes turnstile and then resource. Every thread that takes both takes them in that order. Reverse it in one place, and a writer holding resource while waiting for turnstile, and another holding turnstile while waiting for resource, are a circular wait.

RuleWhy
Always take several locks in the same global orderbreaks the circular-wait condition, so no cycle can form
Release in the opposite orderkeeps the nesting tidy and avoids holding what you no longer need
Never hold a lock you do not need while waitingbreaks the hold-and-wait condition
Hold a lock for as little code as possiblereduces the chance of any of the above going wrong

The sem_wait(&resource) inside count_lock in every program on this page looks like a breach of the third rule, and it is safe for the reason given earlier: no writer ever asks for count_lock, so there is no cycle for the readers to complete. A rule of thumb is not a proof, and the proof is always "is there a cycle".

The three solutions, side by side

Readers preferredWriters preferredFair, with a turnstile
Semaphoresresourceresource, readers_may_enterresource, turnstile
Countersreaders insidereaders inside, writers waitingreaders inside
Readers shareyesyes, between writersyes
Writers exclusiveyesyesyes
Starveswritersreadersnobody
Complexitylowesthighestlow
Use it whenreads far outnumber writes and staleness is acceptablewrites must never be overtakenalmost always
munotes.in91

Practical 6: the Readers-Writers Problem

Procedure

  1. Write the first program. Compile with gcc -Wall -Wextra -pthread -o rw1 rw1.c and run it five

times. Check all four printed lines each time.

  1. Replace the whole reader protocol with a plain sem_wait(&resource) and sem_post(&resource),

so readers are exclusive too. Run it again: the answers are still right, and the most readers inside at once is now 1. That is a lock, not a readers-writers solution.

  1. Remove count_lock from the reader's entry protocol and run it twenty times. Note what

happens, and why two readers arriving together can both try to take resource.

  1. Write the starving program. Run it as timeout 5 ./starve; echo $? and record the 124.
  2. Add the turnstile to it, four lines, and run it again. Record that the writer gets in.
  3. Write the fair version of the full program and confirm the four answers are unchanged.
  4. In your journal, write out the difference between deadlock and starvation in two sentences.

Result

Several readers were allowed into shared data at once while a writer had it exclusively, using one semaphore taken collectively by the readers and a counter under its own mutex. More than one reader was inside together on every run, no reader ever observed a half-written record, and no write was lost. Under a continuous load of readers the reader-preference solution starved the writer entirely, confirmed by the program being killed by a five-second timeout on every run. Adding a turnstile of one semaphore let the writer in on every run, with the four readers still sharing, which is fairness without loss.

Where marks are lost

  • Using a plain mutex for the readers. It is correct and it is not the exercise. The number of

readers inside at once must be able to exceed one, and reporting that is how you show it.

  • Printing the greatest number of readers inside as though it were fixed. It is not: it

depends on the scheduler, and this page saw both 4 and 3 with four readers.

  • No mutex on readers_inside. Two readers arriving together can both believe they are the

first, and the second blocks for ever.

  • The first reader locking and every reader unlocking, or the other way round. It is the

first in that locks and the last out that unlocks.

  • Saying starvation is a deadlock. In a deadlock nothing progresses; under starvation

everybody else is working normally.

  • Claiming a solution is fair because it worked once. Fairness shows up only under load, which
munotes.in92

Practical 6: the Readers-Writers Problem

is why the starving program has readers that never pause.

  • Forgetting that writers-preferred starves readers. Neither priority is fair; that is the

point of the third solution.

  • Taking two semaphores in different orders in different threads, which is a circular wait.
  • Not saying why sem_wait inside a mutex is safe here, when the examiner points at it.

For the journal

Write the aim, MU's own wording, and the table of who may be inside with whom. Then the first program in full with its four printed lines, and say what each line proves. Then the starving program, the timeout 5 command and the 124, and the turnstile version with its output, as a pair: those two are the evidence for the fairness bullet. Add the deadlock-against-starvation table. The conclusion: readers may share because reading changes nothing, a writer must be alone, the classical solution starves writers, and one extra semaphore that everybody queues at makes it fair without taking the sharing away.

Quick revision

  • Many readers together, or one writer alone, and never both.
  • Reader preference: the first reader in takes resource, the last reader out releases it, and

readers_inside is a shared counter needing its own mutex.

  • Report whether more than one reader was ever inside at once. If it never was, you have written a

lock and not a readers-writers solution. Do not print the greatest number as a fact: it depends on the scheduler and changes between runs.

  • Prove correctness with two fields that must agree, such as value and doubled. A reader that

sees them disagree has read a half-written record.

  • Reader preference starves writers; writer preference starves readers. Neither is fair.
  • Starvation is not deadlock: under starvation the rest of the system is working.
  • A turnstile is one semaphore everybody passes through before entering. A writer holds it while

waiting, so readers arriving later queue behind it. Four lines, and the starvation is gone.

  • Deadlock prevention here is one rule: every thread takes the semaphores in the same order, and

releases in the opposite order.

  • Blocking while holding a lock is safe only when no cycle can form. The proof is always to look

for a cycle, not to recite the rule.

Questions you should be able to answer

1. State the readers-writers rule. Any number of readers may be inside together, or one writer alone. A writer may never be inside with a reader or with another writer.

2. Why may readers share when a mutex would not allow it? Because reading does not change the data, so two readers cannot interfere with each other. Only writing can leave the data in a state that another thread must not see.

munotes.in93

Practical 6: the Readers-Writers Problem

3. In the reader-preference solution, which reader takes the semaphore and which releases it? The first reader to arrive takes it and the last to leave releases it. The readers in between do not touch it.

4. Why does readers_inside need a mutex of its own? Because it is shared. Without it two readers arriving together could both see the count go from 0 to 1 and both try to take resource, and the second would block for ever.

5. Why does the program report only that more than one reader was inside, and not how many? Because the greatest number depends on when the scheduler ran each thread and changes between runs: with four readers this page saw both 4 and 3. That more than one was inside together is a property of the program; the exact number is a coincidence of one run.

6. What is writer starvation, and how was it shown on this page? The writer never gets its turn because readers keep arriving and the count never reaches zero. It was shown by running the program under two readers that never pause: it printed nothing and was killed by a five-second timeout, three times out of three.

7. What is the difference between starvation and deadlock? In a deadlock every thread in the cycle is stuck and nothing at all progresses. Under starvation one thread never gets its turn while the rest of the system works normally.

8. What does a turnstile do, and what does it cost? It is one semaphore that every thread passes through before entering. A writer holds it while it waits, so readers that arrive later queue behind the writer. It costs two extra semaphore operations per reader and nothing in sharing: four readers still get in together.

9. What is the one rule that prevents deadlock in this exercise? Every thread takes the semaphores in the same order, turnstile before resource, and releases in the opposite order. That makes a circular wait impossible.

10. The reader calls sem_wait(&resource) while holding count_lock. Is that not blocking while holding a lock? It is, and it is safe here because the only thread that can hold resource is a writer, and a writer never asks for count_lock. No cycle can form, so no deadlock is possible.

Contents This chapter on its own page

munotes.in94

Chapter Twelve

Practical 7: CPU Scheduling, FCFS and Non-preemptive Scheduling

Syllabus topic Module 1, "CPU Scheduling Algorithms (Part 1), FCFS and Non-preemptive Scheduling: Simulate First-Come First-Serve scheduling. Extend implementation to general non-preemptive scheduling. Analyze waiting time, turnaround time, and Gantt chart generation."

Aim

To simulate first come first serve scheduling, to extend the same program to non-preemptive shortest job first and non-preemptive priority scheduling, and to compute waiting time, turnaround time and a Gantt chart for each.

What you need to know before you start

The processor can run one process at a time. Several are usually ready. CPU scheduling is the rule that decides which one goes next, and the short-term scheduler, or dispatcher, is the part of the operating system that applies it.

The five words, and the arithmetic

Every question on this topic is answered with these definitions, so they are worth learning exactly.

TermWhat it isHow it is computed
Arrival time, ATwhen the process became readygiven
Burst time, BThow long it needs the processorgiven
Completion time, CTwhen it finishedread off the schedule
Turnaround time, TAThow long it was in the system altogetherCT minus AT
Waiting time, WThow long it spent ready but not runningTAT minus BT
Response timefrom arrival until it first runsfirst start minus AT

Two of those are worth a second look.

Turnaround time is what the user feels. It counts the waiting and the running together: the time from asking for something to getting it.

Waiting time is what the scheduler is judged on. The burst time is the process's own fault and no scheduler can change it, so the part a scheduler can improve is the waiting.

For a non-preemptive scheduler, where a process once started runs to the end, the response time equals the waiting time, because a process starts once and never stops. That stops being true in [Practical 8: CPU Scheduling, Round Robin].

Preemptive and non-preemptive

Non-preemptivePreemptive
Once a process startsit runs until it finishes or blocksit can be stopped and resumed later
Context switchesas few as possiblemany
A long processholds the processoris interrupted
Good forbatch workinteractive work
MU setsPractical 7: FCFS, SJF, priorityPractical 8: round robin

This chapter is the non-preemptive half. Every algorithm in it picks a process, lets it run to completion, and only then picks again.

The scheduling criteria

An examiner may ask what a scheduler is trying to do, and the five standard answers are:

  • Processor utilisation, kept as high as possible.
  • Throughput, the number of processes finished per unit of time.
  • Turnaround time, minimised.
  • Waiting time, minimised.
  • Response time, minimised, which matters most for anything a person is sitting in front of.

They conflict. A scheduler that minimises the average waiting time is not the one that gives the best worst case, and the rest of this chapter shows that happening.

munotes.in95

Practical 7: CPU Scheduling, FCFS and Non-preemptive Scheduling

The program

One program does all three algorithms, because MU's second bullet asks for the implementation to be extended rather than rewritten. The only thing that changes between them is which ready process is chosen next, so that choice is one if inside one loop and everything else is shared.

#include <stdio.h>
#include <string.h>

#define MAX 10

struct process {
    char name[6];
    int  arrival;
    int  burst;
    int  priority;
    int  start;
    int  finish;
};

/* the Gantt chart: one segment per slice of the processor */
struct slice { char name[6]; int from; int to; };
static struct slice chart[2 * MAX];
static int slices;

static void add_slice(const char *name, int from, int to)
{
    snprintf(chart[slices].name, sizeof chart[slices].name, "%s", name);
    chart[slices].from = from;
    chart[slices].to = to;
    slices++;
}

static void print_chart(void)
{
    printf("Gantt chart\n  ");
    for (int i = 0; i < slices; i++) printf("|%-4s", chart[i].name);
    printf("|\n  ");
    for (int i = 0; i < slices; i++) printf("%-5d", chart[i].from);
    printf("%d\n", chart[slices - 1].to);
}

static void report(struct process *p, int n, const char *title)
{
    printf("%s\n", title);
    print_chart();
    printf("  P   AT  BT  CT  TAT  WT\n");
    int total_tat = 0, total_wt = 0;
    for (int i = 0; i < n; i++) {
        int tat = p[i].finish - p[i].arrival;
        int wt  = tat - p[i].burst;
        total_tat += tat;
        total_wt  += wt;
        printf("  %-3s %2d  %2d  %2d  %3d  %2d\n",
               p[i].name, p[i].arrival, p[i].burst, p[i].finish, tat, wt);
    }
    printf("  average turnaround time = %d / %d = %.2f\n", total_tat, n, (double) total_tat / n);
    printf("  average waiting time    = %d / %d = %.2f\n", total_wt, n, (double) total_wt / n);
    printf("\n");
}

/* run the set under a rule: pick(available) returns the index to run next */
static void schedule(struct process *p, int n, int rule)
{
    int done[MAX] = { 0 };
    int now = 0, finished = 0;
    slices = 0;
    while (finished < n) {
        int best = -1;
        for (int i = 0; i < n; i++) {
            if (done[i] || p[i].arrival > now) continue;
            if (best < 0) { best = i; continue; }
            if (rule == 0) {                                /* FCFS */
                if (p[i].arrival < p[best].arrival) best = i;
            } else if (rule == 1) {                         /* shortest job first */
                if (p[i].burst < p[best].burst
                    || (p[i].burst == p[best].burst && p[i].arrival < p[best].arrival))
                    best = i;
            } else {                                        /* priority, 1 is highest */
                if (p[i].priority < p[best].priority
                    || (p[i].priority == p[best].priority && p[i].arrival < p[best].arrival))
                    best = i;
            }
        }
        if (best < 0) {                 /* nobody has arrived: the processor is idle */
            int next = 1 << 30;
            for (int i = 0; i < n; i++)
                if (!done[i] && p[i].arrival < next) next = p[i].arrival;
            add_slice("idle", now, next);
            now = next;
            continue;
        }
        p[best].start = now;
        p[best].finish = now + p[best].burst;
        add_slice(p[best].name, p[best].start, p[best].finish);
        now = p[best].finish;
        done[best] = 1;
        finished++;
    }
}
int main(void)
{
    struct process set[4] = {
        { "P1", 0, 7, 2, 0, 0 },
        { "P2", 2, 4, 1, 0, 0 },
        { "P3", 4, 1, 3, 0, 0 },
        { "P4", 5, 4, 2, 0, 0 },
    };
    struct process work[4];

    memcpy(work, set, sizeof set);
    schedule(work, 4, 0);
    report(work, 4, "First come first serve");

    memcpy(work, set, sizeof set);
    schedule(work, 4, 1);
    report(work, 4, "Shortest job first, non-preemptive");

    memcpy(work, set, sizeof set);
    schedule(work, 4, 2);
    report(work, 4, "Priority, non-preemptive, 1 is the highest");
    return 0;
}
munotes.in96

Practical 7: CPU Scheduling, FCFS and Non-preemptive Scheduling

First come first serve
Gantt chart
  |P1  |P2  |P3  |P4  |
  0    7    11   12   16
  P   AT  BT  CT  TAT  WT
  P1   0   7   7    7   0
  P2   2   4  11    9   5
  P3   4   1  12    8   7
  P4   5   4  16   11   7
  average turnaround time = 35 / 4 = 8.75
  average waiting time    = 19 / 4 = 4.75

Shortest job first, non-preemptive
Gantt chart
  |P1  |P3  |P2  |P4  |
  0    7    8    12   16
  P   AT  BT  CT  TAT  WT
  P1   0   7   7    7   0
  P2   2   4  12   10   6
  P3   4   1   8    4   3
  P4   5   4  16   11   7
  average turnaround time = 32 / 4 = 8.00
  average waiting time    = 16 / 4 = 4.00

Priority, non-preemptive, 1 is the highest
Gantt chart
  |P1  |P2  |P4  |P3  |
  0    7    11   15   16
  P   AT  BT  CT  TAT  WT
  P1   0   7   7    7   0
  P2   2   4  11    9   5
  P3   4   1  16   12  11
  P4   5   4  15   10   6
  average turnaround time = 38 / 4 = 9.50
  average waiting time    = 22 / 4 = 5.50

Reading the program

schedule(work, n, rule) is the whole of it. At each step it looks at every process that has arrived and is not finished, picks one according to rule, and runs it to completion. Then it moves the clock to that process's finish time and picks again.

The three rules are three comparisons.

rulePicksTie broken by
0the earliest arrivalnothing; arrivals here are distinct
1the shortest burstthe earlier arrival
2the best priority, 1 being highestthe earlier arrival

The tie-break is not decoration. In this job set P2 and P4 both have a burst of 4, and under shortest job first the program must choose one. Breaking the tie by arrival time makes the answer the same every run and the same as the examiner's model answer, which is usually worked in arrival order. A program that leaves the tie to the order of the array will sometimes disagree with the marking scheme.

munotes.in97

Practical 7: CPU Scheduling, FCFS and Non-preemptive Scheduling

memcpy(work, set, sizeof set) before each run puts the job set back as it was, because schedule writes each process's start and finish into it. Without it the second algorithm would be working on the first one's results.

The idle branch is the part most students leave out. If nothing has arrived yet, the processor is idle: the clock must jump forward to the next arrival, and the Gantt chart must show the gap. There is a program below that produces one.

Working the first table by hand

A student must be able to do this on paper, so here is the FCFS row by row.

The processes are taken in arrival order: P1, P2, P3, P4.

ProcessRuns fromRuns toCTTAT = CT - ATWT = TAT - BT
P10777 - 0 = 77 - 7 = 0
P27111111 - 2 = 99 - 4 = 5
P311121212 - 4 = 88 - 1 = 7
P412161616 - 5 = 1111 - 4 = 7

The averages are the sums over four:

  • average turnaround time = (7 + 9 + 8 + 11) / 4 = 35 / 4 = 8.75
  • average waiting time = (0 + 5 + 7 + 7) / 4 = 19 / 4 = 4.75

P1 waits nothing, because it arrived first and the processor was free. P3 waits 7 even though it needs the processor for only 1, because it arrived at 4 and P2 was in front of it. That disproportion is the whole criticism of FCFS.

And the shortest-job-first table, to see where it differs

At time 0 only P1 has arrived, so P1 runs whatever the rule is. At time 7 all three of the others are waiting, and now the rule matters: P3 needs 1, P2 needs 4, P4 needs 4. P3 goes first.

ProcessRuns fromRuns toCTTATWT
P107770
P378843
P281212106
P4121616117
  • average turnaround time = (7 + 10 + 4 + 11) / 4 = 32 / 4 = 8.00
  • average waiting time = (0 + 6 + 3 + 7) / 4 = 16 / 4 = 4.00

Running the one-unit job first saved P3 four units of waiting and cost P2 one, so the average came down from 4.75 to 4.00.

munotes.in98

Practical 7: CPU Scheduling, FCFS and Non-preemptive Scheduling

The three algorithms compared on this job set

AlgorithmOrder runAverage TATAverage WT
First come first serveP1 P2 P3 P48.754.75
Shortest job firstP1 P3 P2 P48.004.00
PriorityP1 P2 P4 P39.505.50

Shortest job first gives the lowest average waiting time, and that is not a coincidence. It is provably optimal among non-preemptive rules for a fixed set of jobs: moving a shorter job ahead of a longer one always reduces the total waiting. Priority did worst here because P3, the one-unit job, has the worst priority and was made to wait for everything.

The convoy effect

The strongest criticism of FCFS is worth a program of its own, because the numbers are dramatic. Three jobs, all ready at time 0, and the first one is long.

#include <stdio.h>
#include <string.h>

#define MAX 10

struct process {
    char name[6];
    int  arrival;
    int  burst;
    int  priority;
    int  start;
    int  finish;
};

/* the Gantt chart: one segment per slice of the processor */
struct slice { char name[6]; int from; int to; };
static struct slice chart[2 * MAX];
static int slices;

static void add_slice(const char *name, int from, int to)
{
    snprintf(chart[slices].name, sizeof chart[slices].name, "%s", name);
    chart[slices].from = from;
    chart[slices].to = to;
    slices++;
}

static void print_chart(void)
{
    printf("Gantt chart\n  ");
    for (int i = 0; i < slices; i++) printf("|%-4s", chart[i].name);
    printf("|\n  ");
    for (int i = 0; i < slices; i++) printf("%-5d", chart[i].from);
    printf("%d\n", chart[slices - 1].to);
}

static void report(struct process *p, int n, const char *title)
{
    printf("%s\n", title);
    print_chart();
    printf("  P   AT  BT  CT  TAT  WT\n");
    int total_tat = 0, total_wt = 0;
    for (int i = 0; i < n; i++) {
        int tat = p[i].finish - p[i].arrival;
        int wt  = tat - p[i].burst;
        total_tat += tat;
        total_wt  += wt;
        printf("  %-3s %2d  %2d  %2d  %3d  %2d\n",
               p[i].name, p[i].arrival, p[i].burst, p[i].finish, tat, wt);
    }
    printf("  average turnaround time = %d / %d = %.2f\n", total_tat, n, (double) total_tat / n);
    printf("  average waiting time    = %d / %d = %.2f\n", total_wt, n, (double) total_wt / n);
    printf("\n");
}

/* run the set under a rule: pick(available) returns the index to run next */
static void schedule(struct process *p, int n, int rule)
{
    int done[MAX] = { 0 };
    int now = 0, finished = 0;
    slices = 0;
    while (finished < n) {
        int best = -1;
        for (int i = 0; i < n; i++) {
            if (done[i] || p[i].arrival > now) continue;
            if (best < 0) { best = i; continue; }
            if (rule == 0) {                                /* FCFS */
                if (p[i].arrival < p[best].arrival) best = i;
            } else if (rule == 1) {                         /* shortest job first */
                if (p[i].burst < p[best].burst
                    || (p[i].burst == p[best].burst && p[i].arrival < p[best].arrival))
                    best = i;
            } else {                                        /* priority, 1 is highest */
                if (p[i].priority < p[best].priority
                    || (p[i].priority == p[best].priority && p[i].arrival < p[best].arrival))
                    best = i;
            }
        }
        if (best < 0) {                 /* nobody has arrived: the processor is idle */
            int next = 1 << 30;
            for (int i = 0; i < n; i++)
                if (!done[i] && p[i].arrival < next) next = p[i].arrival;
            add_slice("idle", now, next);
            now = next;
            continue;
        }
        p[best].start = now;
        p[best].finish = now + p[best].burst;
        add_slice(p[best].name, p[best].start, p[best].finish);
        now = p[best].finish;
        done[best] = 1;
        finished++;
    }
}
int main(void)
{
    /* every job is ready at time 0, and one of them is very long */
    struct process set[3] = {
        { "P1", 0, 20, 1, 0, 0 },
        { "P2", 0,  2, 1, 0, 0 },
        { "P3", 0,  2, 1, 0, 0 },
    };
    struct process work[3];

    memcpy(work, set, sizeof set);
    schedule(work, 3, 0);
    report(work, 3, "First come first serve: the long job is first");

    memcpy(work, set, sizeof set);
    schedule(work, 3, 1);
    report(work, 3, "Shortest job first: the same three jobs");
    return 0;
}
munotes.in99

Practical 7: CPU Scheduling, FCFS and Non-preemptive Scheduling

First come first serve: the long job is first
Gantt chart
  |P1  |P2  |P3  |
  0    20   22   24
  P   AT  BT  CT  TAT  WT
  P1   0  20  20   20   0
  P2   0   2  22   22  20
  P3   0   2  24   24  22
  average turnaround time = 66 / 3 = 22.00
  average waiting time    = 42 / 3 = 14.00

Shortest job first: the same three jobs
Gantt chart
  |P2  |P3  |P1  |
  0    2    4    24
  P   AT  BT  CT  TAT  WT
  P1   0  20  24   24   4
  P2   0   2   2    2   0
  P3   0   2   4    4   2
  average turnaround time = 30 / 3 = 10.00
  average waiting time    = 6 / 3 = 2.00

Fourteen against two. The same three jobs, the same processor, the same total work of 24 units, and the average waiting time is seven times worse under first come first serve.

That is the convoy effect: one long process at the front holds up everything behind it, the way one slow lorry holds up a line of cars on a single-lane road. The two short jobs each wait twenty units to do two units of work.

  • average waiting time under FCFS = (0 + 20 + 22) / 3 = 42 / 3 = 14.00
  • average waiting time under SJF = (4 + 0 + 2) / 3 = 6 / 3 = 2.00

Notice what it cost: P1 now waits 4 where before it waited 0. Shortest job first improves the average by making the long job worse, and that is its own criticism:

munotes.in100

Practical 7: CPU Scheduling, FCFS and Non-preemptive Scheduling

AdvantageDisadvantage
FCFSsimple, fair in the sense that nobody is overtaken, no starvationthe convoy effect, and bad average waiting time
SJFprovably the lowest average waiting timeneeds the burst time in advance, and a long job can starve if short ones keep arriving
Priorityimportant work firststarvation of low-priority work, cured by ageing

Starvation and ageing are worth a sentence each, because both come up in the viva. A long job under SJF, or a low-priority job under priority scheduling, may be overtaken for ever if better candidates keep arriving. Ageing is the standard cure: improve a waiting process's priority a little for every unit of time it has waited, so that everything eventually reaches the front.

And the burst time is not actually known. No operating system is told in advance how long a process will use the processor, so real schedulers estimate it from how long the process used it last time, usually with an exponential average. That is why SJF is a benchmark to compare against rather than a scheduler anybody ships.

An idle processor, which most students forget

If nothing has arrived, the processor waits. The Gantt chart has to show it, and the clock has to jump.

#include <stdio.h>
#include <string.h>

#define MAX 10

struct process {
    char name[6];
    int  arrival;
    int  burst;
    int  priority;
    int  start;
    int  finish;
};

/* the Gantt chart: one segment per slice of the processor */
struct slice { char name[6]; int from; int to; };
static struct slice chart[2 * MAX];
static int slices;

static void add_slice(const char *name, int from, int to)
{
    snprintf(chart[slices].name, sizeof chart[slices].name, "%s", name);
    chart[slices].from = from;
    chart[slices].to = to;
    slices++;
}

static void print_chart(void)
{
    printf("Gantt chart\n  ");
    for (int i = 0; i < slices; i++) printf("|%-4s", chart[i].name);
    printf("|\n  ");
    for (int i = 0; i < slices; i++) printf("%-5d", chart[i].from);
    printf("%d\n", chart[slices - 1].to);
}

static void report(struct process *p, int n, const char *title)
{
    printf("%s\n", title);
    print_chart();
    printf("  P   AT  BT  CT  TAT  WT\n");
    int total_tat = 0, total_wt = 0;
    for (int i = 0; i < n; i++) {
        int tat = p[i].finish - p[i].arrival;
        int wt  = tat - p[i].burst;
        total_tat += tat;
        total_wt  += wt;
        printf("  %-3s %2d  %2d  %2d  %3d  %2d\n",
               p[i].name, p[i].arrival, p[i].burst, p[i].finish, tat, wt);
    }
    printf("  average turnaround time = %d / %d = %.2f\n", total_tat, n, (double) total_tat / n);
    printf("  average waiting time    = %d / %d = %.2f\n", total_wt, n, (double) total_wt / n);
    printf("\n");
}

/* run the set under a rule: pick(available) returns the index to run next */
static void schedule(struct process *p, int n, int rule)
{
    int done[MAX] = { 0 };
    int now = 0, finished = 0;
    slices = 0;
    while (finished < n) {
        int best = -1;
        for (int i = 0; i < n; i++) {
            if (done[i] || p[i].arrival > now) continue;
            if (best < 0) { best = i; continue; }
            if (rule == 0) {                                /* FCFS */
                if (p[i].arrival < p[best].arrival) best = i;
            } else if (rule == 1) {                         /* shortest job first */
                if (p[i].burst < p[best].burst
                    || (p[i].burst == p[best].burst && p[i].arrival < p[best].arrival))
                    best = i;
            } else {                                        /* priority, 1 is highest */
                if (p[i].priority < p[best].priority
                    || (p[i].priority == p[best].priority && p[i].arrival < p[best].arrival))
                    best = i;
            }
        }
        if (best < 0) {                 /* nobody has arrived: the processor is idle */
            int next = 1 << 30;
            for (int i = 0; i < n; i++)
                if (!done[i] && p[i].arrival < next) next = p[i].arrival;
            add_slice("idle", now, next);
            now = next;
            continue;
        }
        p[best].start = now;
        p[best].finish = now + p[best].burst;
        add_slice(p[best].name, p[best].start, p[best].finish);
        now = p[best].finish;
        done[best] = 1;
        finished++;
    }
}
int main(void)
{
    /* nothing arrives between time 2 and time 5, so the processor is idle */
    struct process set[3] = {
        { "P1", 0, 2, 1, 0, 0 },
        { "P2", 5, 3, 1, 0, 0 },
        { "P3", 6, 2, 1, 0, 0 },
    };
    struct process work[3];

    memcpy(work, set, sizeof set);
    schedule(work, 3, 0);
    report(work, 3, "First come first serve with an idle processor");
    return 0;
}
munotes.in101

Practical 7: CPU Scheduling, FCFS and Non-preemptive Scheduling

First come first serve with an idle processor
Gantt chart
  |P1  |idle|P2  |P3  |
  0    2    5    8    10
  P   AT  BT  CT  TAT  WT
  P1   0   2   2    2   0
  P2   5   3   8    3   0
  P3   6   2  10    4   2
  average turnaround time = 9 / 3 = 3.00
  average waiting time    = 2 / 3 = 0.67

P1 finishes at 2 and P2 does not arrive until 5, so the processor is idle for three units. The chart names the gap idle, which is what an examiner looks for.

Two things follow that are easy to get wrong:

The idle time is not waiting time. No process was waiting, so nothing is added to anybody's waiting time. P2 arrives at 5, starts at 5 and waits 0.

Utilisation is now less than 100 per cent. The processor was busy for 7 of the 10 units, so utilisation is 7 / 10 = 0.70, which is 70 per cent. Every earlier job set in this chapter kept the processor busy from start to finish, so its utilisation was 100 per cent.

munotes.in102

Practical 7: CPU Scheduling, FCFS and Non-preemptive Scheduling

Drawing a Gantt chart by hand

The program prints one, and in the examination you draw it. The rules an examiner marks against:

  1. One box per slice of the processor, in the order they ran, with the process name inside.
  2. The times underneath, at the boundaries, starting at 0 and ending at the last completion.
  3. Gaps shown, labelled idle, not closed up.
  4. No gap where there is none. Boxes for consecutive processes share an edge.

For the first job set on this page:

 |  P1  |  P2  | P3 |  P4  |
 0      7      11   12     16

From that picture alone you can read off every completion time, and therefore every turnaround and waiting time, which is why it is worth drawing before filling in the table.

Procedure

  1. Write the program. Compile with gcc -Wall -Wextra -o sched sched.c and run it.
  2. Work the FCFS table out on paper first and check your answers against the program's.
  3. Change rule to 1 and 2 in turn if your version takes it from the command line, or read the

three reports the program prints.

  1. Change P3's burst time to 9 and predict what shortest job first will do before running it.
  2. Write the convoy program and record both averages.
  3. Write the idle-processor program and compute the utilisation by hand.
  4. Add a process that arrives at 20, after everything else has finished, and check that the chart

shows a second idle gap.

Result

First come first serve, non-preemptive shortest job first and non-preemptive priority scheduling were simulated over one job set of four processes. The Gantt chart, completion time, turnaround time and waiting time were produced for each, and the averages were 4.75, 4.00 and 5.50 units of waiting respectively, so shortest job first gave the lowest average waiting time. The convoy effect was demonstrated on three jobs ready together, where first come first serve gave an average waiting time of 14.00 against 2.00 for shortest job first. An idle processor was shown in the Gantt chart and the utilisation computed as 70 per cent.

Where marks are lost

  • Turnaround time computed as CT minus 0 instead of CT minus AT. It is only the same thing

when the process arrived at time 0.

  • Waiting time computed from the start time when the process arrived later. WT is TAT minus

BT, which is safe in every case.

  • Ignoring arrival times and scheduling everything as though it were ready at 0. Half the

marks in a scheduling question are in the arrival times.

  • No idle gap in the chart when the processor had nothing to run.
  • Adding the idle time to somebody's waiting time.
  • Not breaking ties by arrival time, so the answer disagrees with the marking scheme.
  • Forgetting to reset the job set between two algorithms in one program.
  • Averaging over the wrong count, usually because an idle slice was counted as a process.
  • Saying SJF is the best scheduler. It gives the lowest average waiting time and it needs the
munotes.in103

Practical 7: CPU Scheduling, FCFS and Non-preemptive Scheduling

burst time in advance, which nobody has.

For the journal

Write the aim, MU's own wording, the job set as a table, and then for each of the three algorithms the Gantt chart, the completion, turnaround and waiting times, and the two averages. Show one average as a full sum, so the working is visible. Then the convoy pair with both averages, and one sentence on what SJF cost P1 to gain it. The conclusion: a non-preemptive scheduler chooses once per process and the choice changes the average waiting time considerably; shortest job first is optimal for average waiting time and unimplementable as it stands, and first come first serve is implementable and suffers from the convoy effect.

Quick revision

  • CT is read off the schedule. TAT = CT - AT. WT = TAT - BT. Response time is first start minus

AT, and equals WT for a non-preemptive scheduler.

  • Non-preemptive: a process runs to completion once started. Preemptive: it can be interrupted.
  • FCFS runs the earliest arrival. SJF runs the shortest burst. Priority runs the best priority,

with 1 usually the highest. All three break ties by arrival time.

  • On the job set of this chapter the average waiting times are FCFS 4.75, SJF 4.00, priority 5.50.
  • SJF gives the provably lowest average waiting time for a fixed set of jobs, and needs a burst

time nobody knows in advance. Real systems estimate it from the last one.

  • The convoy effect: one long job at the front of an FCFS queue makes every short job behind it

wait. Fourteen against two on the three jobs in this chapter.

  • Starvation under SJF or priority is cured by ageing: raise a waiting process's priority as it

waits.

  • An idle processor is a labelled gap in the Gantt chart, adds to nobody's waiting time, and

brings utilisation below 100 per cent.

  • The five criteria: utilisation, throughput, turnaround time, waiting time, response time. They

conflict.

Questions you should be able to answer

1. Define turnaround time and waiting time. Turnaround time is completion time minus arrival time, the whole time the process was in the system. Waiting time is turnaround time minus burst time, the part of it the process spent ready but not running.

2. Why is waiting time the figure a scheduler is judged on? Because the burst time belongs to the process and no scheduling rule can change it. The waiting is the only part the scheduler decides.

munotes.in104

Practical 7: CPU Scheduling, FCFS and Non-preemptive Scheduling

3. What is the convoy effect? Under first come first serve, one long process at the head of the queue makes every short process behind it wait for the whole of it. On the three jobs on this page it made the average waiting time 14.00 instead of 2.00.

4. Which non-preemptive algorithm gives the lowest average waiting time, and what is wrong with it? Shortest job first, provably. It needs the burst time in advance, which no operating system has, and a long job may starve if short ones keep arriving.

5. What is ageing, and what problem does it solve? Raising the priority of a process a little for every unit of time it has waited. It solves starvation under priority scheduling and under shortest job first.

6. A process arrives at 5, starts at 5 and runs for 3. What are its turnaround and waiting times? Completion 8, turnaround 8 - 5 = 3, waiting 3 - 3 = 0.

7. The processor is idle from 2 to 5. Whose waiting time does that add to? Nobody's. No process was ready during that time. It reduces utilisation, which here was 7 of 10 units, or 70 per cent.

8. Two processes have the same burst time under shortest job first. Which goes first, and why does it matter? The one that arrived earlier. It matters because a program that leaves the choice to the order of the array will sometimes give a different answer from the marking scheme.

9. For a non-preemptive scheduler, how does response time differ from waiting time? It does not: a process starts once and runs to the end, so the time until it first runs is the whole of its waiting. They differ under a preemptive scheduler such as round robin.

Contents This chapter on its own page

munotes.in105

Chapter Thirteen

Practical 8: CPU Scheduling, Round Robin

Syllabus topic Module 1, "CPU Scheduling Algorithms (Part 2), Round Robin: Implement Round Robin scheduling with configurable time quantum. Compare with FCFS: fairness, turnaround, response time. Track context switches and improve queue management."

Aim

To implement round robin scheduling with a configurable time quantum, to compare it with first come first serve on fairness, turnaround time and response time, and to count the context switches it costs.

What you need to know before you start

Every algorithm in [Practical 7: CPU Scheduling, FCFS and Non-preemptive Scheduling] let a process run until it finished. Round robin does not: each process gets a fixed slice of the processor, called the time quantum or time slice, and if it has not finished when the slice runs out it goes to the back of the queue and the next process starts.

That is the whole algorithm. It is first come first serve with a clock, and it is what every interactive operating system uses at the bottom of its scheduler, because it is the simplest rule that never lets one process monopolise the machine.

First come first serveRound robin
Preemptivenoyes, by the clock
A process runs forits whole burstat most one quantum
A long processholds the processor to the endis interrupted and re-queued
Starvationnonenone
Context switchesone per processone per quantum used
Good atthroughputresponse time
Needsnothinga timer interrupt and a quantum

Round robin cannot starve anybody, and that is its strongest property. With n processes in the queue and a quantum of q, no process waits longer than (n - 1) times q before its next turn. That is a guarantee neither shortest job first nor priority scheduling can make.

Response time, which is the figure this algorithm exists for

Practical 7 noted that for a non-preemptive scheduler the response time equals the waiting time, because a process starts once. Under round robin they are different, and the difference is the point:

  • Response time is from arrival until the process first runs.
  • Waiting time is the total time it spent ready and not running, added up over all its turns.

A person typing at a terminal notices the response time. They press a key and either something happens or it does not. Round robin is what makes something happen quickly, at the cost of everything taking slightly longer overall.

The program

The job set is the one from Practical 7, so the two chapters can be compared directly: P1 arrives at 0 and needs 7, P2 at 2 needing 4, P3 at 4 needing 1, P4 at 5 needing 4.

#include <stdio.h>
#include <stdlib.h>
#include <string.h>

#define MAX  10
#define SLOTS 64

struct process {
    char name[6];
    int  arrival;
    int  burst;
    int  left;            /* burst time still to do */
    int  first_run;       /* -1 until it first runs */
    int  finish;
};

struct slice { char name[6]; int from; int to; };
static struct slice chart[SLOTS];
static int slices;

/* the ready queue, held as a circular queue of process indices */
static int queue[SLOTS];
static int head, tail, count;

static void enqueue(int i)
{
    queue[tail] = i;
    tail = (tail + 1) % SLOTS;
    count++;
}

static int dequeue(void)
{
    int i = queue[head];
    head = (head + 1) % SLOTS;
    count--;
    return i;
}

static void add_slice(const char *name, int from, int to)
{
    snprintf(chart[slices].name, sizeof chart[slices].name, "%s", name);
    chart[slices].from = from;
    chart[slices].to = to;
    slices++;
}

static int round_robin(struct process *p, int n, int quantum)
{
    int now = 0, finished = 0, switches = 0, arrived = 0;
    slices = head = tail = count = 0;
    for (int i = 0; i < n; i++) {
        p[i].left = p[i].burst;
        p[i].first_run = -1;
    }

    while (finished < n) {
        /* everything that has arrived by now joins the queue, in arrival order */
        while (arrived < n && p[arrived].arrival <= now)
            enqueue(arrived++);

        if (count == 0) {                       /* nothing ready: idle */
            int next = p[arrived].arrival;
            add_slice("idle", now, next);
            now = next;
            continue;
        }

        int i = dequeue();
        if (p[i].first_run < 0)
            p[i].first_run = now;

        int run = p[i].left < quantum ? p[i].left : quantum;
        add_slice(p[i].name, now, now + run);
        now += run;
        p[i].left -= run;

        /* anything that arrived WHILE it ran joins before it goes back */
        while (arrived < n && p[arrived].arrival <= now)
            enqueue(arrived++);

        if (p[i].left == 0) {
            p[i].finish = now;
            finished++;
            if (finished < n)
                switches++;                     /* one switch to the next process */
        } else {
            enqueue(i);
            switches++;                         /* preempted: one switch */
        }
    }
    return switches;
}

static void report(struct process *p, int n, int quantum, int switches)
{
    printf("Round robin, quantum %d\n", quantum);
    printf("Gantt chart\n  ");
    for (int i = 0; i < slices; i++) printf("|%-4s", chart[i].name);
    printf("|\n  ");
    for (int i = 0; i < slices; i++) printf("%-5d", chart[i].from);
    printf("%d\n", chart[slices - 1].to);

    printf("  P   AT  BT  CT  TAT  WT  RT\n");
    int tat_total = 0, wt_total = 0, rt_total = 0;
    for (int i = 0; i < n; i++) {
        int tat = p[i].finish - p[i].arrival;
        int wt  = tat - p[i].burst;
        int rt  = p[i].first_run - p[i].arrival;
        tat_total += tat; wt_total += wt; rt_total += rt;
        printf("  %-3s %2d  %2d  %2d  %3d  %2d  %2d\n",
               p[i].name, p[i].arrival, p[i].burst, p[i].finish, tat, wt, rt);
    }
    printf("  average turnaround time = %d / %d = %.2f\n", tat_total, n, (double) tat_total / n);
    printf("  average waiting time    = %d / %d = %.2f\n", wt_total, n, (double) wt_total / n);
    printf("  average response time   = %d / %d = %.2f\n", rt_total, n, (double) rt_total / n);
    printf("  context switches        = %d\n", switches);
    printf("\n");
}

int main(int argc, char **argv)
{
    struct process set[4] = {
        { "P1", 0, 7, 0, -1, 0 },
        { "P2", 2, 4, 0, -1, 0 },
        { "P3", 4, 1, 0, -1, 0 },
        { "P4", 5, 4, 0, -1, 0 },
    };
    struct process work[4];
    int quantum = (argc > 1) ? atoi(argv[1]) : 3;
    if (quantum < 1) {
        fprintf(stderr, "the quantum must be at least 1\n");
        return 1;
    }
    memcpy(work, set, sizeof set);
    int switches = round_robin(work, 4, quantum);
    report(work, 4, quantum, switches);
    return 0;
}
munotes.in106

Practical 8: CPU Scheduling, Round Robin

Round robin, quantum 3
Gantt chart
  |P1  |P2  |P1  |P3  |P4  |P2  |P1  |P4  |
  0    3    6    9    10   13   14   15   16
  P   AT  BT  CT  TAT  WT  RT
  P1   0   7  15   15   8   0
  P2   2   4  14   12   8   1
  P3   4   1  10    6   5   5
  P4   5   4  16   11   7   5
  average turnaround time = 44 / 4 = 11.00
  average waiting time    = 28 / 4 = 7.00
  average response time   = 11 / 4 = 2.75
  context switches        = 7
munotes.in107

Practical 8: CPU Scheduling, Round Robin

Reading the program

The ready queue is a circular queue, which is MU's own phrase in Practical 5 and is the right structure here for the same reason: items join at one end and leave at the other, and the array is reused round and round.

static void enqueue(int i)
{ queue[tail] = i; tail = (tail + 1) % SLOTS; count++; }

static int dequeue(void)
{ int i = queue[head]; head = (head + 1) % SLOTS; count--; return i; }

That is the whole of MU's "improve queue management" bullet. A first attempt at this program usually searches the array for the next process to run, which is a loop over every process at every step; a circular queue makes taking the next one a single operation whatever the number of processes.

p[i].left is the burst time still to do. A process is put back on the queue while left is greater than 0 and finishes when it reaches 0. This is the one field a non-preemptive simulation does not need.

The order of two lines decides the answer, and getting it wrong is the classic bug. A process that arrives while the current one is running joins the queue before the process that has just been preempted. That is the convention every textbook and every marking scheme uses, and it changes the Gantt chart: in the run above, P3 arrives at 4 while P2 is running its slice from 3 to 6, so at time 6 the queue holds P1, P3, P4 and not P1, P4, P3.

munotes.in108

Practical 8: CPU Scheduling, Round Robin

The idle branch is here too, for the same reason as in Practical 7: if the queue is empty and nothing has arrived, the clock jumps to the next arrival.

Following the run by hand

This is the part to be able to do on paper, so here is the quantum of 3, step by step.

TimeQueue beforeRunsUntilThen
0P1P13P2 arrived at 2 and joins, then P1 goes back: P2 P1
3P2 P1P26P3 and P4 arrived and join, then P2 goes back: P1 P3 P4 P2
6P1 P3 P4 P2P19P1 has 1 left and goes back: P3 P4 P2 P1
9P3 P4 P2 P1P310P3 needed only 1, so it finishes
10P4 P2 P1P413P4 has 1 left and goes back: P2 P1 P4
13P2 P1 P4P214P2 needed only 1 more, so it finishes
14P1 P4P115P1 finishes
15P4P416P4 finishes

From the completion times the rest follows. P1 finishes at 15, arrived at 0, so its turnaround is 15 and its waiting is 15 - 7 = 8. It first ran at 0, so its response time is 0. P3 finishes at 10, arrived at 4, turnaround 6, waiting 6 - 1 = 5, and it first ran at 9, so its response time is 9 - 4 = 5.

  • average turnaround time = (15 + 12 + 6 + 11) / 4 = 44 / 4 = 11.00
  • average waiting time = (8 + 8 + 5 + 7) / 4 = 28 / 4 = 7.00
  • average response time = (0 + 1 + 5 + 5) / 4 = 11 / 4 = 2.75

What the context switch count counts

A context switch is the operating system saving one process's registers and loading another's. It is pure overhead: no user work happens during it.

The program counts one switch every time the processor changes hands, which is once for every slice except the last:

CauseHow many at quantum 3
a process used its whole quantum and went back on the queue4
a process finished and another took over3
total7

There were eight slices in that Gantt chart and seven changes of hand between them, which is the figure to expect: the number of slices minus one.

Comparing the quanta, which is the experiment

MU's second and third bullets. The same four jobs, run at nine different quanta, in one program. It is the program above with a different main, and the whole of it is printed so that it can be typed in and compiled on its own.

munotes.in109

Practical 8: CPU Scheduling, Round Robin

#include <stdio.h>
#include <stdlib.h>
#include <string.h>

#define MAX  10
#define SLOTS 64

struct process {
    char name[6];
    int  arrival;
    int  burst;
    int  left;            /* burst time still to do */
    int  first_run;       /* -1 until it first runs */
    int  finish;
};

struct slice { char name[6]; int from; int to; };
static struct slice chart[SLOTS];
static int slices;

/* the ready queue, held as a circular queue of process indices */
static int queue[SLOTS];
static int head, tail, count;

static void enqueue(int i)
{
    queue[tail] = i;
    tail = (tail + 1) % SLOTS;
    count++;
}

static int dequeue(void)
{
    int i = queue[head];
    head = (head + 1) % SLOTS;
    count--;
    return i;
}

static void add_slice(const char *name, int from, int to)
{
    snprintf(chart[slices].name, sizeof chart[slices].name, "%s", name);
    chart[slices].from = from;
    chart[slices].to = to;
    slices++;
}

static int round_robin(struct process *p, int n, int quantum)
{
    int now = 0, finished = 0, switches = 0, arrived = 0;
    slices = head = tail = count = 0;
    for (int i = 0; i < n; i++) {
        p[i].left = p[i].burst;
        p[i].first_run = -1;
    }

    while (finished < n) {
        /* everything that has arrived by now joins the queue, in arrival order */
        while (arrived < n && p[arrived].arrival <= now)
            enqueue(arrived++);

        if (count == 0) {                       /* nothing ready: idle */
            int next = p[arrived].arrival;
            add_slice("idle", now, next);
            now = next;
            continue;
        }

        int i = dequeue();
        if (p[i].first_run < 0)
            p[i].first_run = now;

        int run = p[i].left < quantum ? p[i].left : quantum;
        add_slice(p[i].name, now, now + run);
        now += run;
        p[i].left -= run;

        /* anything that arrived WHILE it ran joins before it goes back */
        while (arrived < n && p[arrived].arrival <= now)
            enqueue(arrived++);

        if (p[i].left == 0) {
            p[i].finish = now;
            finished++;
            if (finished < n)
                switches++;                     /* one switch to the next process */
        } else {
            enqueue(i);
            switches++;                         /* preempted: one switch */
        }
    }
    return switches;
}

static void row(struct process *p, int n, int quantum)
{
    struct process work[MAX];
    memcpy(work, p, (size_t) n * sizeof *p);
    int switches = round_robin(work, n, quantum);

    int tat = 0, wt = 0, rt = 0;
    for (int i = 0; i < n; i++) {
        int t = work[i].finish - work[i].arrival;
        tat += t;
        wt  += t - work[i].burst;
        rt  += work[i].first_run - work[i].arrival;
    }
    printf("  %7d %10.2f %10.2f %10.2f %9d\n", quantum,
           (double) tat / n, (double) wt / n, (double) rt / n, switches);
}

int main(void)
{
    struct process set[4] = {
        { "P1", 0, 7, 0, -1, 0 },
        { "P2", 2, 4, 0, -1, 0 },
        { "P3", 4, 1, 0, -1, 0 },
        { "P4", 5, 4, 0, -1, 0 },
    };
    int quanta[] = { 1, 2, 3, 4, 5, 6, 7, 8, 16 };

    printf("the same four jobs at nine different quanta\n");
    printf("  quantum    avg TAT     avg WT     avg RT  switches\n");
    for (size_t i = 0; i < sizeof quanta / sizeof *quanta; i++)
        row(set, 4, quanta[i]);
    return 0;
}
munotes.in110

Practical 8: CPU Scheduling, Round Robin

the same four jobs at nine different quanta
  quantum    avg TAT     avg WT     avg RT  switches
        1       9.50       5.50       0.75        15
        2       9.00       5.00       1.50         8
        3      11.00       7.00       2.75         7
        4       8.50       4.50       3.25         4
        5       9.50       5.50       3.25         4
        6      10.25       6.25       4.00         4
        7       8.75       4.75       4.75         3
        8       8.75       4.75       4.75         3
       16       8.75       4.75       4.75         3

That table is the whole chapter, and it says four things.

1. The response time falls as the quantum falls, smoothly

Read the fourth column from the bottom up: 4.75, 4.75, 4.75, 4.00, 3.25, 3.25, 2.75, 1.50, 0.75. It never goes the wrong way. A smaller quantum means every process gets its first turn sooner, and that is what round robin is for.

At a quantum of 1 the average response time is 0.75 units for jobs whose bursts are up to 7. That is the behaviour that makes a machine feel quick.

2. The context switches rise as the quantum falls, steeply

The last column, again from the bottom: 3, 3, 3, 4, 4, 4, 7, 8, 15. Going from a quantum of 8 to a quantum of 1 buys 4 units of response time and costs five times as many context switches.

That is the trade-off, and it is the answer to how the quantum should be chosen:

The quantum must be large compared with the cost of a context switch, and small compared with

the time a process typically wants the processor for.

Real numbers: a context switch costs a few microseconds, and Linux's default time slice is a few milliseconds, so the overhead is well under one per cent. A quantum of one microsecond would spend most of the machine switching.

3. Once the quantum reaches the longest burst, round robin IS first come first serve

Look at the last three rows: quantum 7, 8 and 16 give identical figures, and those figures, average turnaround 8.75 and average waiting 4.75, are exactly the first come first serve numbers from [Practical 7: CPU Scheduling, FCFS and Non-preemptive Scheduling]. The Gantt chart is the same too: P1 from 0 to 7, P2 to 11, P3 to 12, P4 to 16.

The longest burst in this job set is 7. With a quantum of 7 or more no process is ever preempted, so nothing ever goes back on the queue, so the queue is served in arrival order and never reordered. Round robin with a very large quantum degenerates into first come first serve, and this is the measured proof of a sentence every textbook states.

munotes.in111

Practical 8: CPU Scheduling, Round Robin

4. The waiting time is NOT a smooth function of the quantum

This is the result worth reporting honestly, because it contradicts the impression a student takes from most descriptions of the algorithm.

Read the third column downwards: 5.50, 5.00, 7.00, 4.50, 5.50, 6.25, 4.75, 4.75, 4.75.

It goes down, then sharply up at a quantum of 3, then down, then up twice, then settles. A quantum of 3 is the worst of the nine on this job set, and worse than the quantum of 1 that causes five times as many context switches.

The reason is arithmetic rather than principle. Whether round robin is kind to a particular process depends on whether the quantum divides its burst time neatly and on where the other processes' slices happen to fall. With a quantum of 4, P2's burst of 4 fits in exactly one slice and P2 is never preempted at all; with a quantum of 3 it takes two slices, so P2 must go round the queue again and wait behind everybody. The set here has bursts of 7, 4, 1 and 4, and 4 divides two of them.

Three conclusions follow, and they are the honest ones:

  • The quantum is chosen for response time and overhead, not for waiting time. Waiting time is

what shortest job first optimises, and round robin does not try to.

  • A single job set cannot tell you the best quantum. This one prefers 4; another will prefer

something else. What is general is the response time falling and the switches rising.

  • A measurement that surprises you is worth printing. The table above was run before this

section was written, and the section was written to explain it.

Round robin against first come first serve

MU's second bullet names three things to compare, and the table above answers all three.

First come first serveRound robin, quantum 3
Average turnaround time8.7511.00
Average waiting time4.757.00
Average response time4.752.75
Context switches37
Longest a process waits for its first turn75

Round robin is worse on every total and better on the one thing a user notices. Turnaround up by a quarter, waiting up by nearly a half, response time down by two units, and more than twice the switches.

That is the correct summary of the two algorithms and it is what an examiner is looking for. Round robin is not an improvement on first come first serve in any arithmetic sense; it is a different objective. A batch system that runs payroll overnight wants first come first serve or shortest job first. A machine with people sitting at it wants round robin.

munotes.in112

Practical 8: CPU Scheduling, Round Robin

The two senses of fairness

Both algorithms are called fair and they mean different things by it.

  • First come first serve is fair in order: a process that arrives first is served first, and

nobody ever overtakes anybody. Its unfairness is in the waiting, where a short job behind a long one waits enormously.

  • Round robin is fair in share: every process gets the same size of slice, and none waits more

than one round for its next turn. Its unfairness is that a short job is interrupted just as often as a long one, so short jobs finish later than they need to.

Neither is fair in the sense of shortest job first, which is optimal for the average and openly unfair to long jobs.

Procedure

  1. Write the program. Compile with gcc -Wall -Wextra -o rr rr.c.
  2. Run it as ./rr 3 and check the Gantt chart against the step table above.
  3. Run it at quanta 1, 2, 4, 5, 7, 8 and 16 and write every average down.
  4. Confirm that at a quantum of 7 or more the answer is identical to your first come first serve

answer from Practical 7, including the Gantt chart.

  1. Move the enqueueing of newly arrived processes to after the preempted process goes back on

the queue. Run at quantum 3 again and note that the chart changes.

  1. Write the comparison program and reproduce the table of nine quanta.
  2. Add a fifth process arriving at 30 and confirm the chart shows an idle gap.

Result

Round robin was implemented with the quantum as a command line argument and a circular queue as the ready queue. At a quantum of 3 the four processes gave an average turnaround time of 11.00, an average waiting time of 7.00, an average response time of 2.75 and 7 context switches, against 8.75, 4.75, 4.75 and 3 for first come first serve on the same job set. Over nine quanta the average response time fell monotonically as the quantum fell, from 4.75 to 0.75, while the context switches rose from 3 to 15. At a quantum of 7, equal to the longest burst, round robin produced exactly the first come first serve schedule. The average waiting time was found not to be monotonic in the quantum, being worst at a quantum of 3.

Where marks are lost

  • Putting the preempted process back on the queue before the processes that arrived during its slice.
munotes.in113

Practical 8: CPU Scheduling, Round Robin

The convention is the other way round, and the Gantt chart is then wrong.

  • Not tracking the remaining burst time, so a process runs its whole burst and the program is

really first come first serve.

  • Computing the response time as the waiting time. Under round robin they differ, and that is

the point of the algorithm.

  • Searching the array for the next process instead of keeping a queue. It gives the same

answer and misses MU's bullet about queue management.

  • Not counting the context switches, or counting one per process instead of one per slice.
  • Saying round robin has a better turnaround time than first come first serve. It is worse,

measurably.

  • Saying a smaller quantum is always better. It improves the response time and costs switches,

and on this job set it does not even improve the waiting time reliably.

  • Forgetting the idle case.

For the journal

Write the aim, MU's own wording, the job set, and the quantum 3 run in full: the Gantt chart, the table with the response time column, the three averages and the switch count. Add the step table showing the queue at each instant, because that is what an examiner asks you to produce. Then the nine-quantum table, and three sentences: the response time falls as the quantum falls, the switches rise, and at a quantum equal to the longest burst round robin becomes first come first serve. The conclusion: round robin trades turnaround time for response time and cannot starve anybody.

Quick revision

  • Round robin gives each process at most one quantum, then moves it to the back of the queue. It

is first come first serve with a clock.

  • The ready queue is a circular queue: advance the tail modulo the array size to join, the head to

leave.

  • A process that arrives during a slice joins the queue before the process that has just been

preempted.

  • Response time is until the first run; waiting time is the total time ready and not running. They

are equal only for a non-preemptive scheduler.

  • No process waits longer than (n - 1) times q for its next turn, so round robin cannot starve

anybody.

  • A smaller quantum: better response time, more context switches. The quantum should be large

against the cost of a switch and small against a typical burst.

  • At a quantum greater than or equal to the longest burst, round robin is exactly first come first

serve. Proved here at quanta 7, 8 and 16, all giving 8.75 and 4.75.

  • On the job set of this chapter: first come first serve gives turnaround 8.75, waiting 4.75,

response 4.75 and 3 switches; round robin at quantum 3 gives 11.00, 7.00, 2.75 and 7.

munotes.in114

Practical 8: CPU Scheduling, Round Robin

  • The average waiting time is not monotonic in the quantum. It was worst at 3 of the nine quanta

tried, because whether the quantum divides a burst neatly matters more than its size.

  • First come first serve is fair in order, round robin is fair in share, and neither is optimal

for the average.

Questions you should be able to answer

1. What is the time quantum, and what happens when it runs out? The fixed slice of processor time each process gets. When it runs out, a process that has not finished is preempted and put at the back of the ready queue, and the next process runs.

2. Why can round robin not starve a process? Because the queue is served in order and every process gets at most one quantum. With n processes and a quantum of q, nobody waits longer than (n

  • 1) times q for its next turn.

3. How do response time and waiting time differ under round robin? Response time is the time from arrival until the process first runs, once. Waiting time is the total of all the periods it spent ready and not running, which under round robin is several periods.

4. What happens to the schedule as the quantum becomes very large? No process is ever preempted, so round robin becomes first come first serve. On this job set, quanta of 7, 8 and 16 all gave the first come first serve schedule and its averages exactly.

5. What happens as the quantum becomes very small? The response time improves and the number of context switches rises, here from 3 switches at a quantum of 8 to 15 at a quantum of 1. With a quantum near the cost of a switch, most of the machine's time is spent switching.

6. Compare round robin with first come first serve on this job set. Round robin at quantum 3 is worse on turnaround, 11.00 against 8.75, and worse on waiting, 7.00 against 4.75, and better on response, 2.75 against 4.75, at the cost of 7 context switches against 3.

7. A process arrives while another is using its slice. Where does it go in the queue? Before the process that is about to be preempted. That is the standard convention and it changes the Gantt chart.

8. Is a smaller quantum always better for the average waiting time? No. On the job set here the averages went 5.50, 5.00, 7.00, 4.50 for quanta 1, 2, 3 and 4: a quantum of 3 was the worst of the nine tried. Whether the quantum divides a process's burst neatly matters more than how large it is.

munotes.in115

Practical 8: CPU Scheduling, Round Robin

9. What does a context switch cost, and how does the program count them? It is the saving of one process's state and the loading of another's, and no user work happens during it. The program counts one every time the processor changes hands, which is the number of slices in the Gantt chart minus one.

Contents This chapter on its own page

munotes.in116

Chapter Fourteen

Practical 9: Memory Management, FIFO and LRU Page Replacement

Syllabus topic Module 1, "Memory Management Techniques: Simulate FIFO and LRU page replacement using page reference strings. Measure hit/miss ratios under different reference patterns. Extend to include frames and memory constraints."

Aim

To simulate FIFO and LRU page replacement over a page reference string, to measure the hit and miss ratios, and to see what changes when the number of frames changes.

What you need to know before you start

A program's memory is cut into fixed-size pieces called pages, and the physical memory is cut into pieces of the same size called frames. A page that a program is using has to be in a frame; a page it is not using can sit on the disk. The page table records, for each page, whether it is in memory and which frame it is in.

Demand paging is bringing a page in only when it is actually needed. When a program touches a page that is not in a frame, the hardware raises a page fault, the operating system finds the page on the disk and loads it, and the instruction is tried again.

If every frame is already full, something has to go. Page replacement is choosing what.

TermMeaning
Pagea fixed-size piece of a program's address space, typically 4 kibibytes
Framea piece of physical memory of the same size
Page faulta reference to a page that is not in any frame
Hita reference to a page that is already in a frame
Reference stringthe sequence of page numbers a program touches, in order
Hit ratiohits divided by references
Miss ratiofaults divided by references, which is 1 minus the hit ratio
Victimthe page chosen to be thrown out

Why this matters more than it looks

A hit costs a memory access, about a hundred nanoseconds. A fault costs a disk access, which on a mechanical disk is about eight milliseconds: eighty thousand times slower. So the effective access time is dominated by the faults even when they are rare.

With a fault rate p, an access costing 100 nanoseconds and a fault costing 8 milliseconds:

  • at p = 0.001, one fault in a thousand, the average access is about 8100 nanoseconds
  • at p = 0, it is 100 nanoseconds

One fault in a thousand makes memory eighty times slower. That is why a page replacement algorithm is worth arguing about, and why the numbers in this chapter matter.

The reference string

MU asks for reference strings, and this chapter uses the one from Silberschatz, because its answers are published and can be compared:

7 0 1 2 0 3 0 4 2 3 0 3 2 1 2 0 1 7 0 1

Twenty references to five distinct pages. That is a real program's behaviour in miniature: a few pages used over and over, with occasional visits elsewhere.

munotes.in117

Practical 9: Memory Management, FIFO and LRU Page Replacement

The three algorithms

AlgorithmEvictsCan it be builtWhy it matters
FIFOthe page that came in earliestyes, triviallythe simplest, and the worst-behaved
LRUthe page that has not been used for longestyes, at a costa good approximation of optimal
Optimalthe page that will not be used for longestnothe bound nothing can beat

Optimal cannot be implemented, because it needs to know the future. It is in the program and in this chapter for one reason: it is the standard against which the other two are measured. A student who says "optimal is the best algorithm, so we should use it" has missed the point of it.

The program

One program, three algorithms, because the difference between them is entirely in the choice of victim.

#include <stdio.h>
#include <string.h>

#define MAXF 8
#define MAXR 40

/* FIFO, LRU and optimal all fit one shape: choose a frame to evict. */
enum policy { FIFO, LRU, OPTIMAL };

static const char *name_of(enum policy p)
{
    return p == FIFO ? "FIFO" : p == LRU ? "LRU" : "optimal";
}

static int run(const int *ref, int n, int frames, enum policy how, int show)
{
    int page[MAXF];               /* what is in each frame, -1 for empty   */
    int stamp[MAXF];              /* FIFO: when it came in. LRU: last used */
    int faults = 0;

    for (int f = 0; f < frames; f++) {
        page[f] = -1;
        stamp[f] = 0;
    }

    if (show) {
        printf("  %s with %d frames\n", name_of(how), frames);
        printf("  ref ");
        for (int f = 0; f < frames; f++) printf(" f%d", f);
        printf("  hit/miss\n");
    }

    for (int i = 0; i < n; i++) {
        int want = ref[i];
        int found = -1;

        for (int f = 0; f < frames; f++)
            if (page[f] == want) { found = f; break; }

        if (found >= 0) {
            if (how == LRU) stamp[found] = i;      /* used again, so it is fresh */
        } else {
            faults++;
            int victim = 0;
            int empty = -1;
            for (int f = 0; f < frames; f++)
                if (page[f] == -1) { empty = f; break; }

            if (empty >= 0) {
                victim = empty;
            } else if (how == OPTIMAL) {
                /* evict the page whose next use is furthest away */
                int best = -1;
                for (int f = 0; f < frames; f++) {
                    int next = n + 1;
                    for (int k = i + 1; k < n; k++)
                        if (ref[k] == page[f]) { next = k; break; }
                    if (next > best) { best = next; victim = f; }
                }
            } else {
                /* FIFO: the oldest arrival. LRU: the least recently used.
                   Both are the smallest stamp; only WHEN the stamp is set
                   differs, and that is the whole difference between them. */
                int oldest = stamp[0];
                victim = 0;
                for (int f = 1; f < frames; f++)
                    if (stamp[f] < oldest) { oldest = stamp[f]; victim = f; }
            }
            page[victim] = want;
            stamp[victim] = i;
        }

        if (show) {
            printf("  %3d ", want);
            for (int f = 0; f < frames; f++) {
                if (page[f] == -1) printf("  -");
                else printf(" %2d", page[f]);
            }
            printf("  %s\n", found >= 0 ? "hit" : "MISS");
        }
    }
    return faults;
}

static void summary(const int *ref, int n, int frames, enum policy how)
{
    int faults = run(ref, n, frames, how, 0);
    int hits = n - faults;
    printf("  %-7s %2d frames: %2d faults, %2d hits, hit ratio %d / %d = %.2f\n",
           name_of(how), frames, faults, hits, hits, n, (double) hits / n);
}

int main(void)
{
    /* the reference string every textbook uses for this exercise */
    int ref[] = { 7, 0, 1, 2, 0, 3, 0, 4, 2, 3, 0, 3, 2, 1, 2, 0, 1, 7, 0, 1 };
    int n = (int) (sizeof ref / sizeof *ref);

    printf("the reference string, %d references:\n ", n);
    for (int i = 0; i < n; i++) printf(" %d", ref[i]);
    printf("\n\n");

    run(ref, n, 3, FIFO, 1);
    printf("\n");
    run(ref, n, 3, LRU, 1);
    printf("\n");

    printf("the same string at several frame counts\n");
    for (int f = 1; f <= 5; f++) {
        summary(ref, n, f, FIFO);
        summary(ref, n, f, LRU);
        summary(ref, n, f, OPTIMAL);
        printf("\n");
    }
    return 0;
}
munotes.in118

Practical 9: Memory Management, FIFO and LRU Page Replacement

the reference string, 20 references:
  7 0 1 2 0 3 0 4 2 3 0 3 2 1 2 0 1 7 0 1

  FIFO with 3 frames
  ref  f0 f1 f2  hit/miss
    7   7  -  -  MISS
    0   7  0  -  MISS
    1   7  0  1  MISS
    2   2  0  1  MISS
    0   2  0  1  hit
    3   2  3  1  MISS
    0   2  3  0  MISS
    4   4  3  0  MISS
    2   4  2  0  MISS
    3   4  2  3  MISS
    0   0  2  3  MISS
    3   0  2  3  hit
    2   0  2  3  hit
    1   0  1  3  MISS
    2   0  1  2  MISS
    0   0  1  2  hit
    1   0  1  2  hit
    7   7  1  2  MISS
    0   7  0  2  MISS
    1   7  0  1  MISS

  LRU with 3 frames
  ref  f0 f1 f2  hit/miss
    7   7  -  -  MISS
    0   7  0  -  MISS
    1   7  0  1  MISS
    2   2  0  1  MISS
    0   2  0  1  hit
    3   2  0  3  MISS
    0   2  0  3  hit
    4   4  0  3  MISS
    2   4  0  2  MISS
    3   4  3  2  MISS
    0   0  3  2  MISS
    3   0  3  2  hit
    2   0  3  2  hit
    1   1  3  2  MISS
    2   1  3  2  hit
    0   1  0  2  MISS
    1   1  0  2  hit
    7   1  0  7  MISS
    0   1  0  7  hit
    1   1  0  7  hit

the same string at several frame counts
  FIFO     1 frames: 20 faults,  0 hits, hit ratio 0 / 20 = 0.00
  LRU      1 frames: 20 faults,  0 hits, hit ratio 0 / 20 = 0.00
  optimal  1 frames: 20 faults,  0 hits, hit ratio 0 / 20 = 0.00

  FIFO     2 frames: 15 faults,  5 hits, hit ratio 5 / 20 = 0.25
  LRU      2 frames: 17 faults,  3 hits, hit ratio 3 / 20 = 0.15
  optimal  2 frames: 13 faults,  7 hits, hit ratio 7 / 20 = 0.35

  FIFO     3 frames: 15 faults,  5 hits, hit ratio 5 / 20 = 0.25
  LRU      3 frames: 12 faults,  8 hits, hit ratio 8 / 20 = 0.40
  optimal  3 frames:  9 faults, 11 hits, hit ratio 11 / 20 = 0.55

  FIFO     4 frames: 10 faults, 10 hits, hit ratio 10 / 20 = 0.50
  LRU      4 frames:  8 faults, 12 hits, hit ratio 12 / 20 = 0.60
  optimal  4 frames:  8 faults, 12 hits, hit ratio 12 / 20 = 0.60

  FIFO     5 frames:  9 faults, 11 hits, hit ratio 11 / 20 = 0.55
  LRU      5 frames:  7 faults, 13 hits, hit ratio 13 / 20 = 0.65
  optimal  5 frames:  7 faults, 13 hits, hit ratio 13 / 20 = 0.65
munotes.in119

Practical 9: Memory Management, FIFO and LRU Page Replacement

FIFO and LRU differ by one line

This is the neatest thing in the program and worth pointing out, because it is what makes the two algorithms comparable.

Both keep a stamp for each frame and both evict the frame with the smallest stamp. The only difference is when the stamp is written:

The stamp is set whenSo the victim is
FIFOthe page is loaded, and never againthe page that has been in memory longest
LRUthe page is loaded and every time it is usedthe page unused for longest

In the program that is the single line if (how == LRU) stamp[found] = i; inside the hit branch. Take it out and LRU becomes FIFO. That one line is what an examiner asks about, because it is the whole difference between an algorithm that ignores how a program behaves and one that pays attention to it.

Following the difference in the tables

Look at the two tables at the sixth reference, page 3, when the frames hold 2, 0, 1.

munotes.in120

Practical 9: Memory Management, FIFO and LRU Page Replacement

  • FIFO asks which came in first. The order of arrival was 7, then 0, then 1, and 7 has already

been replaced by 2, so the oldest survivor is 0. It evicts 0. Two references later it needs 0 again, and faults.

  • LRU asks which was used least recently. Page 0 was used at reference 5, one step ago; page 1

has not been touched since reference 3; page 2 came in at reference 4. So LRU evicts 1, keeps 0, and gets a hit on the next reference.

That single decision is why LRU ends with 12 faults and FIFO with 15.

The hit ratio, worked

The definitions, over 20 references with three frames:

  • FIFO: 15 faults and 5 hits, so the hit ratio = 5 / 20 = 0.25 and the miss ratio = 15 / 20 = 0.75
  • LRU: 12 faults and 8 hits, so the hit ratio = 8 / 20 = 0.40 and the miss ratio = 12 / 20 = 0.60
  • optimal: 9 faults and 11 hits, so the hit ratio = 11 / 20 = 0.55

The two ratios add to 1, always, because every reference is either a hit or a fault. An examiner who asks for the miss ratio is asking for one minus the hit ratio.

The first references are always faults, because the frames start empty. With f frames and a string touching at least f distinct pages, the first f faults are unavoidable and are called cold start or compulsory misses. Nine of LRU's twelve faults here are genuine replacement decisions; three are cold start. That is worth saying in a journal, because it means a short reference string flatters no algorithm.

More frames, and what they buy

MU's third bullet asks for frames and memory constraints to be varied, and the table at the bottom of the output is that experiment. Reading the faults column:

FramesFIFOLRUOptimal
1202020
2151713
315129
41088
5977

Four things in that table are worth stating, and three of them surprise people.

With one frame every reference is a fault. Twenty out of twenty, for all three algorithms. There is only one frame, so there is no choice to make and no algorithm can help. It is the floor of the experiment and a useful check that the program is right.

More frames means fewer faults, usually. From 1 to 5 frames, LRU goes 20, 17, 12, 8, 7. That is the behaviour a student expects and the reason buying memory speeds a machine up.

munotes.in121

Practical 9: Memory Management, FIFO and LRU Page Replacement

FIFO with two frames beats LRU with two frames: 15 faults against 17. LRU is a better algorithm in general and it is not better on every string at every frame count. A single measurement never settles which algorithm is better, and this row is the proof.

Optimal is a floor nothing reaches. At three frames it needs 9 where LRU needs 12 and FIFO 15. At four and five frames LRU has caught up with it exactly, which says something about this string rather than about LRU: with enough frames, the good algorithms converge.

Belady's anomaly

The third surprise deserves a program of its own. It is the one result in this topic that most students do not believe until they have run it.

#include <stdio.h>
#include <string.h>

#define MAXF 8
#define MAXR 40

/* FIFO, LRU and optimal all fit one shape: choose a frame to evict. */
enum policy { FIFO, LRU, OPTIMAL };

static const char *name_of(enum policy p)
{
    return p == FIFO ? "FIFO" : p == LRU ? "LRU" : "optimal";
}

static int run(const int *ref, int n, int frames, enum policy how, int show)
{
    int page[MAXF];               /* what is in each frame, -1 for empty   */
    int stamp[MAXF];              /* FIFO: when it came in. LRU: last used */
    int faults = 0;

    for (int f = 0; f < frames; f++) {
        page[f] = -1;
        stamp[f] = 0;
    }

    if (show) {
        printf("  %s with %d frames\n", name_of(how), frames);
        printf("  ref ");
        for (int f = 0; f < frames; f++) printf(" f%d", f);
        printf("  hit/miss\n");
    }

    for (int i = 0; i < n; i++) {
        int want = ref[i];
        int found = -1;

        for (int f = 0; f < frames; f++)
            if (page[f] == want) { found = f; break; }

        if (found >= 0) {
            if (how == LRU) stamp[found] = i;      /* used again, so it is fresh */
        } else {
            faults++;
            int victim = 0;
            int empty = -1;
            for (int f = 0; f < frames; f++)
                if (page[f] == -1) { empty = f; break; }

            if (empty >= 0) {
                victim = empty;
            } else if (how == OPTIMAL) {
                /* evict the page whose next use is furthest away */
                int best = -1;
                for (int f = 0; f < frames; f++) {
                    int next = n + 1;
                    for (int k = i + 1; k < n; k++)
                        if (ref[k] == page[f]) { next = k; break; }
                    if (next > best) { best = next; victim = f; }
                }
            } else {
                /* FIFO: the oldest arrival. LRU: the least recently used.
                   Both are the smallest stamp; only WHEN the stamp is set
                   differs, and that is the whole difference between them. */
                int oldest = stamp[0];
                victim = 0;
                for (int f = 1; f < frames; f++)
                    if (stamp[f] < oldest) { oldest = stamp[f]; victim = f; }
            }
            page[victim] = want;
            stamp[victim] = i;
        }

        if (show) {
            printf("  %3d ", want);
            for (int f = 0; f < frames; f++) {
                if (page[f] == -1) printf("  -");
                else printf(" %2d", page[f]);
            }
            printf("  %s\n", found >= 0 ? "hit" : "MISS");
        }
    }
    return faults;
}

static void summary(const int *ref, int n, int frames, enum policy how)
{
    int faults = run(ref, n, frames, how, 0);
    int hits = n - faults;
    printf("  %-7s %2d frames: %2d faults, %2d hits, hit ratio %d / %d = %.2f\n",
           name_of(how), frames, faults, hits, hits, n, (double) hits / n);
}

int main(void)
{
    /* the string Belady used: FIFO gets WORSE when a frame is added */
    int ref[] = { 1, 2, 3, 4, 1, 2, 5, 1, 2, 3, 4, 5 };
    int n = (int) (sizeof ref / sizeof *ref);

    printf("the reference string, %d references:\n ", n);
    for (int i = 0; i < n; i++) printf(" %d", ref[i]);
    printf("\n\n");

    for (int f = 3; f <= 4; f++) {
        summary(ref, n, f, FIFO);
        summary(ref, n, f, LRU);
        summary(ref, n, f, OPTIMAL);
        printf("\n");
    }
    return 0;
}
munotes.in122

Practical 9: Memory Management, FIFO and LRU Page Replacement

the reference string, 12 references:
  1 2 3 4 1 2 5 1 2 3 4 5

  FIFO     3 frames:  9 faults,  3 hits, hit ratio 3 / 12 = 0.25
  LRU      3 frames: 10 faults,  2 hits, hit ratio 2 / 12 = 0.17
  optimal  3 frames:  7 faults,  5 hits, hit ratio 5 / 12 = 0.42

  FIFO     4 frames: 10 faults,  2 hits, hit ratio 2 / 12 = 0.17
  LRU      4 frames:  8 faults,  4 hits, hit ratio 4 / 12 = 0.33
  optimal  4 frames:  6 faults,  6 hits, hit ratio 6 / 12 = 0.50

FIFO with three frames has 9 faults. FIFO with four frames has 10. More memory, more page faults, on the same program with the same references.

That is Belady's anomaly, named after the researcher who found it in 1969, and it is a real property of FIFO and not a mistake in the program. The reason is that FIFO's victim has nothing to do with what the program is using: adding a frame changes the arrival order of everything that follows, and the page FIFO decides to throw out can easily be one that a smaller memory would have kept.

LRU cannot do this, and neither can optimal. Both are stack algorithms: with n + 1 frames they always hold a superset of what they would hold with n frames, so a page that is present with n frames is present with n + 1, and a hit stays a hit. In the table above LRU goes from 10 faults to 8 and optimal from 7 to 6, both in the right direction.

munotes.in123

Practical 9: Memory Management, FIFO and LRU Page Replacement

That is the useful summary:

Belady's anomaly possibleStack algorithm
FIFOyes, shown aboveno
LRUnoyes
Optimalnoyes

Notice also that on this string LRU with three frames is worse than FIFO with three frames, 10 faults against 9. Two different strings, two different winners: the general claim that LRU is better than FIFO is a claim about programs in general, not about every string.

How LRU is implemented for real

The program uses a timestamp and searches the frames for the smallest, which costs a loop over every frame at every fault. That is fine for five frames and impossible for the million frames of a real machine. Two standard implementations, both worth knowing:

Counters. Every page table entry holds the time of its last use, and the hardware writes it on every reference. Finding the victim is then a search, which is the program above. Simple, and the search is the problem.

A stack. Keep the page numbers in a doubly linked list, and move a page to the top whenever it is used. The bottom is always the least recently used, so there is no search at all. That is the doubly linked list of [Practical 14: Doubly Linked Lists], used for exactly this.

And the honest answer: no real operating system implements true LRU, because updating a timestamp or a list on every single memory reference costs more than it saves. What they use instead is an approximation:

  • The second chance, or clock, algorithm. FIFO with one extra bit. Each page has a

reference bit the hardware sets when the page is used. When FIFO picks a victim, if its reference bit is 1 the bit is cleared and the page is given a second chance rather than evicted. A page in use survives; a page that is not is evicted on the next pass. It is nearly as good as LRU and it costs one bit.

  • Not recently used and enhanced second chance, which add a modify bit so that a page

which has not been written can be dropped without writing it back to the disk.

Linux uses a two-list variation of the clock algorithm, and Windows a working-set scheme. Neither is LRU, and both are trying to be.

munotes.in124

Practical 9: Memory Management, FIFO and LRU Page Replacement

Thrashing, which is what happens when there are too few frames

The table showed faults falling as frames rose. Run the experiment the other way, on a machine with many processes and not enough memory for them, and the faults rise until the machine spends all its time paging and no time computing. That is thrashing.

The signature is easy to recognise and worth knowing for the viva: the processor utilisation drops, the operating system concludes that it should admit more processes, memory gets tighter still, and the faults rise further. A machine in that state is unusable and the disk light is permanently on.

The cures are all about the working set, meaning the set of pages a process is actively using:

  • Give each process enough frames for its working set, and admit fewer processes if there is not

enough memory for all of them.

  • Measure the fault rate and use it directly: too high, give the process more frames; very low,

take some away.

Procedure

  1. Write the program. Compile with gcc -Wall -Wextra -o page page.c and run it.
  2. Check the FIFO and LRU tables for three frames against the textbook: 15 faults and 12.
  3. Remove if (how == LRU) stamp[found] = i; and run it again. LRU now gives FIFO's answer, which

is the proof that the one line is the whole difference.

  1. Work the first eight references of LRU with three frames on paper and compare with the table.
  2. Write the second program and confirm Belady's anomaly: 9 faults at three frames and 10 at four.
  3. Change the reference string to one of your own with ten references and four distinct pages, and

predict the FIFO answer before running it.

  1. Add a frame count of 6 and note that nothing improves, because the string touches only five

distinct pages.

Result

FIFO, LRU and optimal page replacement were simulated over the standard twenty-reference string. With three frames FIFO took 15 page faults, LRU 12 and optimal 9, agreeing with the published figures, giving hit ratios of 0.25, 0.40 and 0.55. The frame count was varied from 1 to 5: with one frame every reference faulted, and LRU's faults fell 20, 17, 12, 8, 7. FIFO was found to beat LRU at two frames, 15 faults against 17. On Belady's string FIFO took 9 faults with three frames and 10 with four, which is Belady's anomaly, while LRU improved from 10 to 8 as a stack algorithm must.

Where marks are lost

  • Setting the LRU stamp only on a fault. The program is then FIFO with a different name, and

the examiner will see the identical answer.

  • Counting the cold-start faults as replacement decisions, or forgetting that the first few
munotes.in125

Practical 9: Memory Management, FIFO and LRU Page Replacement

references must fault.

  • Computing the hit ratio as hits over faults instead of hits over references.
  • Saying more frames always means fewer faults. Under FIFO it does not: that is Belady's

anomaly, and it is a standard question.

  • Saying LRU is always better than FIFO. At two frames on this string it is worse, 17 against

15.

  • Saying optimal should be used. It needs the future.
  • Not showing the frame contents at each step. The examiner marks the working, and the table

of frames is the working.

  • Confusing a page with a frame. A page belongs to the program, a frame to the machine.

For the journal

Write the aim, MU's own wording, the reference string, and the full step tables for FIFO and LRU with three frames, because the frame contents at each step are the working. Under each, the fault count, the hit count and the hit ratio as a division. Then the table of faults against frames from 1 to 5 for all three algorithms, and the Belady run with its two lines. The conclusion: LRU beats FIFO on this string because it evicts by use rather than by age, the two differ by one line of program, and FIFO can get worse when memory is added while LRU cannot.

Quick revision

  • A page belongs to the program, a frame to the machine, and demand paging brings a page in only

when it is touched. A reference to a page not in a frame is a page fault.

  • Hit ratio is hits over references; miss ratio is faults over references; they add to 1.
  • FIFO evicts the earliest arrival. LRU evicts the page unused for longest. Optimal evicts the

page whose next use is furthest away and cannot be implemented.

  • FIFO and LRU differ by one line: LRU refreshes the stamp on a hit as well as on a load.
  • On the standard string with three frames: FIFO 15 faults, LRU 12, optimal 9, hit ratios 0.25,

0.40, 0.55.

  • The first f references with f frames must fault. Those are cold start or compulsory misses.
  • Belady's anomaly: FIFO can take more faults with more frames. Proved here, 9 at three frames

and 10 at four, on the string 1 2 3 4 1 2 5 1 2 3 4 5.

  • LRU and optimal are stack algorithms and cannot show the anomaly, because with n + 1 frames they

hold a superset of what they hold with n.

  • LRU at two frames on the standard string is worse than FIFO. One measurement never settles which

algorithm is better.

  • Real systems approximate LRU with the second chance or clock algorithm: FIFO plus a reference
munotes.in126

Practical 9: Memory Management, FIFO and LRU Page Replacement

bit the hardware sets, cleared to give a used page one more chance.

  • Thrashing is a machine spending all its time paging. The cure is to fit each process's working

set, admitting fewer processes if necessary.

Questions you should be able to answer

1. What is a page fault? A reference to a page that is not in any frame. The hardware traps to the operating system, which loads the page from the disk and restarts the instruction.

2. Which page does FIFO evict, and which does LRU evict? FIFO evicts the page that has been in memory longest, by arrival. LRU evicts the page that has not been used for the longest time.

3. In a program, what is the only difference between FIFO and LRU? Whether the timestamp is refreshed on a hit. FIFO stamps a page when it is loaded and never again; LRU stamps it every time it is used.

4. Twenty references, twelve faults. What are the hit and miss ratios? Eight hits, so the hit ratio is 8 / 20 = 0.40 and the miss ratio is 12 / 20 = 0.60.

5. Does adding a frame always reduce the number of faults? No. Under FIFO it can increase them, which is Belady's anomaly: on the string 1 2 3 4 1 2 5 1 2 3 4 5 FIFO takes 9 faults with three frames and 10 with four.

6. Why can LRU not show Belady's anomaly? Because it is a stack algorithm: with n + 1 frames it always holds a superset of the pages it would hold with n, so a hit with n frames is still a hit with n + 1.

7. Why does no operating system implement true LRU? Because it would have to update a timestamp or a list on every single memory reference, which costs more than the faults it saves. They approximate it with the clock algorithm, which is FIFO plus a reference bit set by the hardware.

8. What is the optimal algorithm for, if it cannot be implemented? It is the bound. It gives the fewest faults any algorithm could take on that string, so it says how much room for improvement a real algorithm has.

9. What is thrashing, and what cures it? A machine spending nearly all its time servicing page faults, because the processes together have less memory than their working sets need. The cure is to give each process enough frames for its working set and to run fewer processes if there is not enough memory.

Contents This chapter on its own page

munotes.in127

Chapter Fifteen

Practical 10: Disk Scheduling

Syllabus topic Module 1, "Disk Scheduling and Simple File System Design: Simulate FCFS, SSTF, C-SCAN, C-LOOK, RSS for disk head movement."

Aim

To simulate FCFS, SSTF, C-SCAN, C-LOOK and RSS disk scheduling over one request queue and to compare the total head movement each of them costs.

What you need to know before you start

A mechanical hard disk stores data on spinning platters. To read a block it must:

  1. Seek: move the arm to the right track, which on a modern disk takes about 4 to 10

milliseconds.

  1. Wait for rotation: until the block comes round under the head, about 2 to 6 milliseconds.
  2. Transfer: the data, which for one block is a fraction of a millisecond.

The seek dominates, and the seek is the only one the operating system can do anything about: it cannot make the platter spin faster, but it can choose the order in which the waiting requests are served. Disk scheduling is that choice, and the figure it is judged on is the total head movement, counted in cylinders, because that is what the seek time is proportional to.

TermMeaning
Cylinder, or trackone ring of the platter; the arm moves between cylinders
Seek timethe time to move the arm, proportional to the distance
Rotational latencythe wait for the block to come round
Request queuethe blocks waiting to be read or written, by cylinder number
Total head movementthe sum of the distances the arm travels, in cylinders

None of this applies to a solid state disk. An SSD has no arm and no platter, so every block costs the same and disk scheduling as taught here has nothing to do. Linux ships a scheduler called none for exactly that case. It is worth saying in a viva, and it does not make the topic useless: the algorithms below are the standard example of scheduling a resource with a position, and mechanical disks are still where bulk storage lives.

The problem

The standard example, and the one MU's Text Book uses. A disk of 200 cylinders numbered 0 to 199, the arm currently at cylinder 53, and eight requests waiting:

98  183  37  122  14  124  65  67

The question every algorithm answers differently: in what order should those eight be served?

The five algorithms MU names, and two more that explain them

AlgorithmRuleTurns round at
FCFSserve them in the order they arrivednowhere; it wanders
SSTFalways serve the nearest requestwherever the requests are
SCANsweep to the end, then sweep back serving on the waythe physical end of the disk
C-SCANsweep to the end, jump back to the start, sweep againthe end, and serves nothing on the jump
LOOKsweep as far as the last request, then sweep backthe last request in that direction
C-LOOKsweep to the last request, jump to the lowest, sweep againthe last request, and serves nothing on the jump
RSSserve them in a random ordernowhere
munotes.in128

Practical 10: Disk Scheduling

The five in bold are MU's. SCAN and LOOK are here because C-SCAN and C-LOOK are defined by the difference from them, and an examiner who asks for C-SCAN expects you to be able to say what the C is for. It stands for circular.

The program

One program, seven algorithms, and four of them share a single function because they differ only in two decisions: does the head go all the way to the end of the disk, and does it serve requests on the way back.

#include <stdio.h>
#include <stdlib.h>
#include <string.h>

#define MAXQ  32
#define LOW   0          /* the lowest cylinder on the disk  */
#define HIGH  199        /* the highest                      */

/* The random order is produced by a generator written out here, not by
   rand(), so that the answer is the same on every machine and every C library.
   rand() is allowed to differ between implementations and does. */
static unsigned long seed = 12345UL;

static int next_random(int n)
{
    seed = seed * 1103515245UL + 12345UL;
    return (int) ((seed >> 16) % (unsigned long) n);
}

static int distance(int a, int b) { return a > b ? a - b : b - a; }

static void report(const char *name, const int *order, int n, int start)
{
    int total = 0, at = start;
    printf("  %-7s ", name);
    printf("%d", start);
    for (int i = 0; i < n; i++) {
        printf(" -> %d", order[i]);
        total += distance(at, order[i]);
        at = order[i];
    }
    printf("\n          total head movement = %d cylinders\n", total);
}

/* first come first serve: the queue in the order it arrived */
static void fcfs(const int *q, int n, int start)
{
    report("FCFS", q, n, start);
}

/* shortest seek time first: always the nearest request */
static void sstf(const int *q, int n, int start)
{
    int left[MAXQ], order[MAXQ], done[MAXQ] = { 0 };
    memcpy(left, q, (size_t) n * sizeof *q);
    int at = start;
    for (int k = 0; k < n; k++) {
        int best = -1;
        for (int i = 0; i < n; i++) {
            if (done[i]) continue;
            if (best < 0 || distance(at, left[i]) < distance(at, left[best])) best = i;
        }
        done[best] = 1;
        order[k] = left[best];
        at = left[best];
    }
    report("SSTF", order, n, start);
}

static int by_value(const void *a, const void *b)
{
    int x = *(const int *) a, y = *(const int *) b;
    return (x > y) - (x < y);
}

/* the four sweeping algorithms differ only in where the head turns round and
   whether it serves requests on the way back */
static void sweep(const char *name, const int *q, int n, int start,
                  int to_the_end, int serve_on_return)
{
    int s[MAXQ], order[MAXQ];
    int k = 0;
    memcpy(s, q, (size_t) n * sizeof *q);
    qsort(s, (size_t) n, sizeof *s, by_value);

    int first_above = n;
    for (int i = 0; i < n; i++)
        if (s[i] >= start) { first_above = i; break; }

    /* going up, serving everything at or above the head */
    for (int i = first_above; i < n; i++) order[k++] = s[i];

    if (to_the_end) order[k++] = HIGH;          /* SCAN and C-SCAN reach the end */

    if (serve_on_return) {                      /* SCAN and LOOK: serve coming back */
        for (int i = first_above - 1; i >= 0; i--) order[k++] = s[i];
    } else {                                    /* C-SCAN and C-LOOK: jump and restart */
        if (to_the_end) order[k++] = LOW;
        for (int i = 0; i < first_above; i++) order[k++] = s[i];
    }
    report(name, order, k, start);
}

/* random scheduling: whatever comes to hand */
static void rss(const int *q, int n, int start)
{
    int left[MAXQ], order[MAXQ];
    memcpy(left, q, (size_t) n * sizeof *q);
    int count = n;
    for (int k = 0; k < n; k++) {
        int pick = next_random(count);
        order[k] = left[pick];
        left[pick] = left[--count];
    }
    report("RSS", order, n, start);
}

int main(void)
{
    int q[] = { 98, 183, 37, 122, 14, 124, 65, 67 };
    int n = (int) (sizeof q / sizeof *q);
    int start = 53;

    printf("disk cylinders %d to %d, head at %d\n", LOW, HIGH, start);
    printf("request queue:");
    for (int i = 0; i < n; i++) printf(" %d", q[i]);
    printf("\n\n");

    fcfs(q, n, start);
    sstf(q, n, start);
    sweep("SCAN",   q, n, start, 1, 1);
    sweep("C-SCAN", q, n, start, 1, 0);
    sweep("LOOK",   q, n, start, 0, 1);
    sweep("C-LOOK", q, n, start, 0, 0);
    rss(q, n, start);
    return 0;
}
munotes.in129

Practical 10: Disk Scheduling

disk cylinders 0 to 199, head at 53
request queue: 98 183 37 122 14 124 65 67

  FCFS    53 -> 98 -> 183 -> 37 -> 122 -> 14 -> 124 -> 65 -> 67
          total head movement = 640 cylinders
  SSTF    53 -> 65 -> 67 -> 37 -> 14 -> 98 -> 122 -> 124 -> 183
          total head movement = 236 cylinders
  SCAN    53 -> 65 -> 67 -> 98 -> 122 -> 124 -> 183 -> 199 -> 37 -> 14
          total head movement = 331 cylinders
  C-SCAN  53 -> 65 -> 67 -> 98 -> 122 -> 124 -> 183 -> 199 -> 0 -> 14 -> 37
          total head movement = 382 cylinders
  LOOK    53 -> 65 -> 67 -> 98 -> 122 -> 124 -> 183 -> 37 -> 14
          total head movement = 299 cylinders
  C-LOOK  53 -> 65 -> 67 -> 98 -> 122 -> 124 -> 183 -> 14 -> 37
          total head movement = 322 cylinders
  RSS     53 -> 14 -> 122 -> 65 -> 67 -> 124 -> 98 -> 183 -> 37
          total head movement = 520 cylinders
munotes.in130

Practical 10: Disk Scheduling

The four sweeps are one function

sweep(name, q, n, start, to_the_end, serve_on_return) is the whole of SCAN, C-SCAN, LOOK and C-LOOK, and the two flags are the whole of the difference:

Algorithmto_the_endserve_on_returnIn words
SCAN11to cylinder 199, then back serving as it goes
C-SCAN10to 199, jump to 0 serving nothing, then up again
LOOK01only as far as 183, then back serving as it goes
C-LOOK00only as far as 183, jump to 14 serving nothing, then up

Writing it that way is worth a mark on its own, because it makes the relationship between the four impossible to get wrong. Two flags, four algorithms.

qsort first. All four sweeps need the requests in cylinder order, so the array is sorted and then the position of the first request at or above the head is found. Everything above that index is served going up; everything below it is served coming down or after the jump.

The random generator is written out in the source. rand() is allowed to differ between C libraries and does, so a program using it would print a different order on a different machine and the page could not claim an answer. The generator here is three lines and gives the same sequence everywhere, which is what makes the RSS line reproducible: the program was run twice and printed the same order and the same 520 both times.

The seven totals, and what they say

AlgorithmTotal head movementCompared with FCFS
SSTF23637 per cent
LOOK29947 per cent
C-LOOK32250 per cent
SCAN33152 per cent
C-SCAN38260 per cent
RSS52081 per cent
FCFS640100 per cent

FCFS is the worst, and it is worth seeing why

Follow the order: 53 to 98 to 183 to 37 to 122 to 14 to 124 to 65 to 67. The arm crosses the middle of the disk five times. The distances are

  • total = 45 + 85 + 146 + 85 + 108 + 110 + 59 + 2 = 640

The 146 in the middle is the arm going from cylinder 183 down to 37 and back up again afterwards. FCFS does no thinking at all, and on a busy disk it is close to the worst possible order.

munotes.in131

Practical 10: Disk Scheduling

Its one virtue is the same as first come first serve for the processor: nobody is overtaken, so nothing starves.

SSTF is the best here, and cannot be trusted

Two hundred and thirty-six cylinders, a little over a third of FCFS. The order goes to the two nearby requests first, then down to the pair below, then up through the rest: 53 to 65 to 67 to 37 to 14 to 98 to 122 to 124 to 183.

  • total = 12 + 2 + 30 + 23 + 84 + 24 + 2 + 59 = 236

SSTF can starve a request, and that is its defect. It is the disk version of shortest job first. If requests keep arriving near the head, a request at cylinder 190 is passed over indefinitely: it is never the nearest, so it is never chosen. On a disk that is being written continuously in one region, a single read at the far edge can wait for an unbounded time.

And SSTF is not optimal, only greedy. On this queue it happens to give the lowest total of the seven, and there is no guarantee of that in general: choosing the nearest request each time can walk the head into a corner it then has to come out of.

SCAN, the elevator

The arm goes up, serving everything on the way, reaches the end of the disk, and comes back down serving everything it passed: 53 up to 199, then down to 14.

  • total = 12 + 2 + 31 + 24 + 2 + 59 + 16 + 162 + 23 = 331

It is called the elevator algorithm because that is exactly what a lift does: it goes to the top serving the floors in order, then comes down serving the rest, and nobody is skipped. The consequence is the property SSTF lacks: no starvation. A request at the far edge waits at most one sweep.

C-SCAN, and why it costs more

The C is for circular. When the arm reaches the end it does not turn round and serve on the way back. It jumps straight to cylinder 0, serving nothing, and sweeps up again: 53 up to 199, jump to 0, up to 37.

  • total = 12 + 2 + 31 + 24 + 2 + 59 + 16 + 199 + 14 + 23 = 382

That is 51 cylinders more than SCAN, and the 199 in the middle is the jump. C-SCAN moves the head further and treats the requests more evenly, and understanding that trade is the point of the algorithm.

munotes.in132

Practical 10: Disk Scheduling

Under SCAN, a cylinder in the middle of the disk is passed twice per sweep and a cylinder at the edge once, so the middle gets better service. Under C-SCAN every cylinder is visited once per sweep, in the same direction, so the waiting time is uniform wherever the request is. A request just behind the head under SCAN may be served almost at once or after a whole double sweep, depending on which way the head is going; under C-SCAN it always waits one sweep.

SCAN minimises the movement. C-SCAN minimises the variation in how long a request waits. On

a disk serving many users, the second is often what you want.

LOOK and C-LOOK, which stop looking sooner

The wasted part of SCAN and C-SCAN is going all the way to cylinder 199 and 0 when the furthest requests are at 183 and 14. LOOK and C-LOOK go only as far as the last request in that direction.

  • LOOK total = 12 + 2 + 31 + 24 + 2 + 59 + 146 + 23 = 299
  • C-LOOK total = 12 + 2 + 31 + 24 + 2 + 59 + 169 + 23 = 322

LOOK saves 32 cylinders over SCAN and C-LOOK saves 60 over C-SCAN, on this queue, by not touching the two ends of the disk. They keep the no-starvation guarantee, because the sweep still passes every waiting request. In practice LOOK and C-LOOK are what real systems implement, and they are usually what is meant when somebody says SCAN or C-SCAN loosely.

RSS, and what it is for

Five hundred and twenty cylinders, worse than everything except FCFS, which is the answer you would expect from serving requests in no order at all.

Random scheduling is not a policy anybody ships. It is in the list, and in this program, as a baseline: it says what the total looks like when the scheduler does nothing useful, so the other algorithms can be measured against something other than each other. FCFS at least follows the arrival order; RSS follows nothing.

One honest note about it, and it is the reason the generator is written out in the program: a random algorithm's answer depends on the random numbers. The 520 above is the total for one particular sequence. Run it with a different seed and the total changes, and the average over many seeds is the meaningful figure. This program fixes the seed so that the answer can be printed and checked, and a student writing it up should say which seed they used.

The comparison to learn

FCFSSSTFSCANC-SCANLOOKC-LOOKRSS
Head movement here640236331382299322520
Starvation possiblenoyesnonononoyes
Uniform waiting timenononoyesnoyesno
Reaches the disk endsnonoyesyesnonono
Serves on the way backn/an/ayesnoyesnon/a
Used in practicelight loadsrarelyas LOOKas C-LOOKyesyesno
munotes.in133

Practical 10: Disk Scheduling

Three sentences that answer most questions on this topic:

FCFS is fair and slow. SSTF is fast and can starve. The sweeping algorithms are the compromise and are what real systems use.

The C in C-SCAN and C-LOOK buys a uniform waiting time and costs extra head movement,

because the return jump serves nothing.

LOOK and C-LOOK are SCAN and C-SCAN without the pointless trip to the end of the disk.

Procedure

  1. Write the program. Compile with gcc -Wall -Wextra -o disk disk.c and run it.
  2. Check the FCFS total of 640 and the SSTF total of 236 by adding the distances on paper.
  3. Draw all seven paths on graph paper, cylinder number up the side and order along the bottom.

The shape of each algorithm is the point, and an examiner may ask for the diagram.

  1. Change the starting head position to 100 and run it again. Note which algorithm changes most.
  2. Change the initial direction of the sweeps by serving the requests below the head first.

C-SCAN's total changes considerably.

  1. Change the seed of the random generator and record three different RSS totals.
  2. Add a request at cylinder 199 and at cylinder 0, and note that LOOK and SCAN now give the same

answer, and so do C-LOOK and C-SCAN.

Result

Seven disk scheduling algorithms were simulated over the queue 98, 183, 37, 122, 14, 124, 65, 67 with the head at cylinder 53 on a disk of 200 cylinders. The total head movement was FCFS 640, SSTF 236, SCAN 331, C-SCAN 382, LOOK 299, C-LOOK 322 and RSS 520 cylinders. The FCFS and SSTF totals agree with the published figures for this queue. SSTF gave the least movement on this queue and is the only one of the five MU names that can starve a request; C-SCAN and C-LOOK cost more movement than SCAN and LOOK and give a uniform waiting time in return.

Where marks are lost

  • Not counting the movement to the end of the disk under SCAN and C-SCAN. The trip to 199 and

the jump to 0 are head movement and are counted.

  • Counting the C-SCAN return jump as serving requests. It serves none; that is what makes it

circular.

  • Confusing SCAN with C-SCAN, or LOOK with C-LOOK. The C means the head jumps back without

serving. LOOK means it stops at the last request instead of the disk end.

munotes.in134

Practical 10: Disk Scheduling

  • Not stating the direction of the first sweep. The answer depends on it, and an examiner

expects it to be written down.

  • Saying SSTF is optimal. It is greedy, it is not optimal, and it can starve a request.
  • Giving an RSS total without saying what order was used. A random answer is not reproducible

unless the sequence is stated.

  • Forgetting that the head position counts as the start and measuring from the first request.
  • Saying disk scheduling matters on an SSD. It does not, and Linux ships a none scheduler

for them.

For the journal

Write the aim, MU's own wording, the disk size, the starting head position and the request queue. For each of the five algorithms MU names, write the service order, the sum of the distances as an addition, and the total. Draw the seven paths on graph paper and paste it in, because the diagram is half the marks in this exercise. Then the comparison table with the starvation and uniform-wait columns, and one sentence saying which algorithm you would choose for a disk serving many users and why. The conclusion: the order the requests are served in changes the total head movement by a factor of nearly three on this queue, and the fastest of them is the one that can starve a request.

Quick revision

  • Seek time dominates a disk access and is proportional to the distance the arm moves, so the

figure to minimise is the total head movement in cylinders.

  • On the queue 98, 183, 37, 122, 14, 124, 65, 67 with the head at 53: FCFS 640, SSTF 236, SCAN

331, C-SCAN 382, LOOK 299, C-LOOK 322, RSS 520.

  • FCFS serves in arrival order: fair, no starvation, worst movement.
  • SSTF serves the nearest: least movement here, greedy rather than optimal, and the only one of

MU's five that can starve a request.

  • SCAN is the elevator: to the end of the disk, then back serving on the way. No starvation.
  • C-SCAN jumps back to the start serving nothing, then sweeps again. More movement than SCAN, and

a uniform waiting time in return.

  • LOOK and C-LOOK are the same two without the trip to the physical end. They are what is used in

practice.

  • RSS is Random Scheduling, a baseline rather than a policy. Its answer depends on the random

sequence, so the seed must be stated.

  • The C stands for circular. The difference between SCAN and LOOK is whether the head reaches the

end of the disk or only the last request.

  • None of this applies to a solid state disk, which has no arm.
munotes.in135

Practical 10: Disk Scheduling

Questions you should be able to answer

1. Why is head movement the figure to minimise? Because the seek time is proportional to the distance the arm moves and dominates a disk access. Rotational latency and transfer time are not things the scheduler can change.

2. Which algorithm gave the least movement here, and why is it still not used? SSTF, with 236 cylinders against FCFS's 640. It can starve a request: a cylinder far from the head is never the nearest, so it may never be chosen while nearby requests keep arriving.

3. What does the C in C-SCAN stand for, and what does it change? Circular. The head does not serve requests on the way back: it jumps to the start of the disk and sweeps in the same direction again. That costs more movement, 382 against SCAN's 331 here, and gives every cylinder the same waiting time.

4. What is the difference between SCAN and LOOK? SCAN goes all the way to the physical end of the disk; LOOK turns round at the last request in that direction. Here that saved 32 cylinders, 299 against 331.

5. Why is C-SCAN's waiting time more uniform than SCAN's? Under SCAN a cylinder in the middle is passed twice per double sweep and a cylinder at the edge once, so the middle is served better. Under C-SCAN every cylinder is visited once per sweep in the same direction, so a request waits one sweep wherever it is.

6. What does RSS stand for and what is it for? Random Scheduling: the requests are served in a random order. It is a baseline for comparison, not a policy anybody ships, and its total depends on the random sequence used.

7. Two algorithms in the list cannot starve a request. Which? Every sweeping algorithm: SCAN, C-SCAN, LOOK and C-LOOK, because the sweep passes every waiting request. FCFS cannot starve anybody either, since nobody is overtaken. SSTF and RSS can.

8. Does any of this apply to a solid state disk? No. There is no arm and no seek, so every block costs the same. Linux provides a scheduler called none for them.

9. The head is at 53 and the requests are at 65 and 37. Which does SSTF serve first? Cylinder 65, which is 12 away, rather than 37, which is 16 away.

Contents This chapter on its own page

munotes.in136

Chapter Sixteen

Practical 10 continued: a Simple File System

Syllabus topic Module 1, "Design a basic file system structure with block allocation, directory management, and file operations (create, read, delete)."

Aim

To design a basic file system: a disk of fixed blocks, a free space bitmap, a directory, and the operations create, read, list and delete.

What you need to know before you start

MU's second bullet for Practical 10 is a different program from the first, and a more interesting one: not how to order requests to a disk, but how to lay data out on one.

To a file system a disk is not a spinning platter, it is a numbered sequence of fixed-size blocks. Block 0, block 1, block 2, and so on. Every design decision follows from two facts:

  • A file is a stream of bytes of any length, and a block is a fixed size, so a file needs

several blocks and the last one is usually not full.

  • The blocks a file uses have to be recorded somewhere, and that record itself has to live on

the disk.

A file system is those two problems solved. It has four parts, and the program below has all four.

PartWhat it holdsIn this programIn Linux
The blocksthe data itselfdisk[32][16]the data blocks
The free space mapwhich blocks are in usebitmap, one character per blocka bitmap in each block group
The directorynames, and where each file's blocks aredirectory[8]directory entries plus inodes
The metadatahow big the disk is, how big a block isthe #define linesthe superblock

The three ways of allocating blocks

This is the part an examiner asks about, and the program implements the third.

Contiguous allocation. A file gets consecutive blocks, and the directory records the first block and the count. Reading is fastest, because the head does not move between blocks. Growing a file is nearly impossible, and the free space breaks into small unusable pieces, which is external fragmentation.

Linked allocation. Each block holds a pointer to the next, and the directory records only the first. A file can grow anywhere and there is no external fragmentation at all. Reading the middle of a file means following the chain from the beginning, so direct access is impossible, and one damaged block loses the rest of the file. This is the singly linked list of [Practical 12: Singly Linked Lists], laid out on a disk.

Indexed allocation. Each file has an index block holding the numbers of all its blocks. Any block can be found at once, the file can grow, and there is no external fragmentation. The cost is the index block itself, which is wasted space for a small file, and a limit on how large a file can be: one index block holds only so many numbers.

munotes.in137

Practical 10 continued: a Simple File System

ContiguousLinkedIndexed
The directory recordsfirst block and lengthfirst blockthe index block
Direct access to block nyes, by arithmeticno, follow the chainyes, one lookup
A file can growbarelyfreelyfreely
External fragmentationyesnono
Overheadnonea pointer per blockone index block per file
Damage to one blockloses that blockloses the rest of the fileloses that block
Used byCD-ROMs, some real-time systemsFAT, in a variationUnix and Linux, in the inode

Linux uses indexed allocation, and the index is called the inode. A classical Unix inode holds twelve block numbers directly, then a pointer to a block of block numbers, then a pointer to a block of pointers to blocks of block numbers, and one more level after that. Small files need no indirection at all; large files pay for it. The program below is the first twelve, simplified to six.

The free space bitmap

The other half of the design: how does the file system know which blocks are free?

A bitmap is one bit per block, 1 for used and 0 for free. Finding a free block is a scan for a zero bit, which on real hardware is fast because whole words can be tested at once. Finding n consecutive free blocks is also easy, which matters for contiguous allocation. The cost is the size: a 1 terabyte disk with 4 kibibyte blocks needs 32 mebibytes of bitmap.

A free list is the alternative: each free block holds the number of the next free block. It costs no extra space at all, since the pointers live in blocks nobody is using, and it cannot find consecutive blocks without walking the whole list.

The program uses a bitmap and prints it as text, one character per block, so that the allocation can be watched.

The program

#include <stdio.h>
#include <string.h>

#define BLOCKS      32        /* blocks on our little disk            */
#define BLOCK_SIZE  16        /* bytes in one block                   */
#define FILES        8        /* entries the directory holds          */
#define NAME_LEN    12
#define INDEX_MAX    6        /* blocks one file may have             */

/* the disk: an array of blocks, which is what a real disk is to the
   file system: a numbered sequence of fixed-size pieces */
static char disk[BLOCKS][BLOCK_SIZE];

/* the free space bitmap: one character per block, '.' free, '#' used */
static char bitmap[BLOCKS + 1];

/* one directory entry, which in a real file system is the inode */
struct entry {
    char name[NAME_LEN];
    int  used;
    int  size;                     /* in bytes                        */
    int  count;                    /* how many blocks it holds        */
    int  index[INDEX_MAX];         /* the block numbers: the index block */
};

static struct entry directory[FILES];

static void format(void)
{
    memset(disk, 0, sizeof disk);
    memset(directory, 0, sizeof directory);
    for (int b = 0; b < BLOCKS; b++) bitmap[b] = '.';
    bitmap[BLOCKS] = '\0';
    bitmap[0] = '#';               /* block 0 is the directory itself */
    printf("formatted: %d blocks of %d bytes, %d directory entries\n",
           BLOCKS, BLOCK_SIZE, FILES);
}

static void show_bitmap(const char *why)
{
    int free_blocks = 0;
    for (int b = 0; b < BLOCKS; b++) if (bitmap[b] == '.') free_blocks++;
    printf("  bitmap %s  (%d of %d blocks free)\n", bitmap, free_blocks, BLOCKS);
    (void) why;
}

static int find(const char *name)
{
    for (int i = 0; i < FILES; i++)
        if (directory[i].used && strcmp(directory[i].name, name) == 0) return i;
    return -1;
}

static int allocate(void)
{
    for (int b = 1; b < BLOCKS; b++)
        if (bitmap[b] == '.') { bitmap[b] = '#'; return b; }
    return -1;
}

static int create(const char *name, const char *data)
{
    if (find(name) >= 0) {
        printf("create %s: a file of that name already exists\n", name);
        return -1;
    }
    int slot = -1;
    for (int i = 0; i < FILES; i++) if (!directory[i].used) { slot = i; break; }
    if (slot < 0) {
        printf("create %s: the directory is full\n", name);
        return -1;
    }

    int len = (int) strlen(data);
    int need = (len + BLOCK_SIZE - 1) / BLOCK_SIZE;   /* round UP */
    if (need == 0) need = 1;
    if (need > INDEX_MAX) {
        printf("create %s: %d bytes needs %d blocks and a file may hold %d\n",
               name, len, need, INDEX_MAX);
        return -1;
    }

    struct entry *e = &directory[slot];
    memset(e, 0, sizeof *e);
    for (int k = 0; k < need; k++) {
        int b = allocate();
        if (b < 0) {
            for (int j = 0; j < k; j++) bitmap[e->index[j]] = '.';  /* give it back */
            printf("create %s: the disk is full\n", name);
            return -1;
        }
        e->index[k] = b;
        int from = k * BLOCK_SIZE;
        int take = len - from;
        if (take > BLOCK_SIZE) take = BLOCK_SIZE;
        if (take > 0) memcpy(disk[b], data + from, (size_t) take);
    }
    snprintf(e->name, sizeof e->name, "%s", name);
    e->used = 1;
    e->size = len;
    e->count = need;

    printf("create %-9s %3d bytes in %d block(s):", name, len, need);
    for (int k = 0; k < need; k++) printf(" %d", e->index[k]);
    printf("\n");
    show_bitmap("after create");
    return slot;
}

static void read_file(const char *name)
{
    int i = find(name);
    if (i < 0) {
        printf("read %s: no such file\n", name);
        return;
    }
    struct entry *e = &directory[i];
    printf("read   %-9s [", name);
    int left = e->size;
    for (int k = 0; k < e->count; k++) {
        int take = left > BLOCK_SIZE ? BLOCK_SIZE : left;
        printf("%.*s", take, disk[e->index[k]]);
        left -= take;
    }
    printf("]\n");
}

static void delete_file(const char *name)
{
    int i = find(name);
    if (i < 0) {
        printf("delete %s: no such file\n", name);
        return;
    }
    struct entry *e = &directory[i];
    printf("delete %-9s freeing block(s):", name);
    for (int k = 0; k < e->count; k++) {
        printf(" %d", e->index[k]);
        bitmap[e->index[k]] = '.';
        memset(disk[e->index[k]], 0, BLOCK_SIZE);
    }
    printf("\n");
    memset(e, 0, sizeof *e);
    show_bitmap("after delete");
}

static void list(void)
{
    printf("directory\n");
    printf("  name        bytes blocks  block numbers\n");
    int files = 0, used = 0;
    for (int i = 0; i < FILES; i++) {
        if (!directory[i].used) continue;
        struct entry *e = &directory[i];
        printf("  %-11s %5d %6d ", e->name, e->size, e->count);
        for (int k = 0; k < e->count; k++) printf(" %d", e->index[k]);
        printf("\n");
        files++;
        used += e->count;
    }
    if (files == 0) printf("  (empty)\n");
    printf("  %d file(s), %d block(s) of data\n", files, used);
}

int main(void)
{
    format();
    show_bitmap("after format");
    printf("\n");

    create("notes.txt", "Computer Science Practical 3, Module 1");
    create("roll.txt",  "2331");
    create("marks.csv", "maths,78\nphysics,65\nchem,92\n");
    printf("\n");

    list();
    printf("\n");

    read_file("notes.txt");
    read_file("marks.csv");
    read_file("absent.txt");
    printf("\n");

    delete_file("roll.txt");
    printf("\n");

    create("new.txt", "this one reuses the block roll.txt gave back");
    printf("\n");

    list();
    return 0;
}
munotes.in138

Practical 10 continued: a Simple File System

formatted: 32 blocks of 16 bytes, 8 directory entries
  bitmap #...............................  (31 of 32 blocks free)

create notes.txt  38 bytes in 3 block(s): 1 2 3
  bitmap ####............................  (28 of 32 blocks free)
create roll.txt    4 bytes in 1 block(s): 4
  bitmap #####...........................  (27 of 32 blocks free)
create marks.csv  28 bytes in 2 block(s): 5 6
  bitmap #######.........................  (25 of 32 blocks free)

directory
  name        bytes blocks  block numbers
  notes.txt      38      3  1 2 3
  roll.txt        4      1  4
  marks.csv      28      2  5 6
  3 file(s), 6 block(s) of data

read   notes.txt [Computer Science Practical 3, Module 1]
read   marks.csv [maths,78
physics,65
chem,92
]
read absent.txt: no such file

delete roll.txt  freeing block(s): 4
  bitmap ####.##.........................  (26 of 32 blocks free)

create new.txt    44 bytes in 3 block(s): 4 7 8
  bitmap #########.......................  (23 of 32 blocks free)

directory
  name        bytes blocks  block numbers
  notes.txt      38      3  1 2 3
  new.txt        44      3  4 7 8
  marks.csv      28      2  5 6
  3 file(s), 8 block(s) of data
munotes.in139

Practical 10 continued: a Simple File System

Reading the output, which is where the design shows

The bitmap starts with block 0 already used. Block 0 is the directory itself. Every real file system reserves its first blocks for its own structures, and a student who allocates block 0 to a file has overwritten the directory.

38 bytes took 3 blocks. The blocks are 16 bytes, and 38 bytes needs three of them: two full and one with 6 bytes in it. The arithmetic is worth writing out, because getting it wrong is the commonest bug in this exercise:

munotes.in140

Practical 10 continued: a Simple File System

int need = (len + BLOCK_SIZE - 1) / BLOCK_SIZE;    /* rounds UP */

38 / 16 in C is 2, because integer division throws the remainder away, and a program that uses it loses the last 6 bytes of every file. Adding BLOCK_SIZE - 1 first rounds up: 38 + 15 is 53, and 53 divided by 16 is 3 and a bit, which integer division cuts to 3.

Note the form that is written there. Putting an equals sign between 53 over 16 and 3 would be wrong, because 53 over 16 is 3.3125; what is true is that C's integer division of 53 by 16 gives

  1. The distinction matters in a journal, and the arithmetic checker on this book refused two

earlier drafts of this paragraph for exactly that reason.

The last block is not full, and that space is lost. Three blocks of 16 bytes is 48 bytes of disk for a 38 byte file, so 48 - 38 = 10 bytes are wasted. That is internal fragmentation, and it is unavoidable with fixed blocks. For new.txt it is 48 - 44 = 4 bytes. On a real file system with 4 kibibyte blocks, a thousand files of one byte each occupy 4 megabytes.

new.txt got blocks 4, 7 and 8, which are not consecutive. Block 4 is the one roll.txt gave back when it was deleted, and blocks 7 and 8 were the next free ones. That is indexed allocation working: the file is scattered and the index block records where the pieces are, so nothing has to be moved and no space is left unusable. Under contiguous allocation the single free block at 4 would have been too small for this file and would have stayed empty.

Deleting freed the blocks and cleared the entry. The bitmap went from seven used blocks to six, with a gap in the middle: ####.##. The blocks are also wiped, which a real file system usually does not do, and that is why deleted files can often be recovered.

Reading a missing file says so. read absent.txt: no such file, rather than crashing or printing rubbish. Every operation in the program checks first, and there are five such checks: the name already exists, the directory is full, the file is too large for its index, the disk is full, and the file does not exist.

The rollback in create, which is worth copying

If the disk fills up halfway through allocating a file, the blocks already taken must be given back:

int b = allocate();
if (b < 0) {
    for (int j = 0; j < k; j++) bitmap[e->index[j]] = '.';   /* give them back */
    printf("create %s: the disk is full\n", name);
    return -1;
}
munotes.in141

Practical 10 continued: a Simple File System

Without those two lines a failed create leaks blocks: they are marked used, no file owns them, and nothing will ever free them. It is exactly the leak of a shared memory segment in [Practical 1: Process Communication using Shared Memory], and the same principle applies: an operation that fails halfway must undo what it has already done.

The directory, which is also the inode here

In this program one structure holds both the name and the block numbers:

struct entry {
    char name[NAME_LEN];
    int  used, size, count;
    int  index[INDEX_MAX];     /* the index block, inline */
};

Unix splits those two things, and knowing why is worth a mark. A directory entry holds only the name and an inode number; the inode holds the size, the permissions, the times and the block numbers. The split is what makes a hard link possible: two names in two directories pointing at one inode, so the file has two names and one set of blocks. The inode keeps a count of how many names point at it, and the blocks are freed only when that count reaches zero.

That is why rm on a file with two hard links does not free any space, and why the system call is called unlink and not delete.

What a real file system adds

The program is about eighty lines and a real one is hundreds of thousands. The differences worth naming:

This programA real file system
Directoriesone, flata tree, each directory a file of entries
Permissionsnoneowner, group, mode, and a check on every open
Timesnonecreated, modified, accessed
Open filesnonea table per process, and file descriptors
Large files6 blocksindirect blocks, so terabytes
Crash safetynonea journal, so an interrupted write can be undone
Cachingnonethe page cache, so most reads never reach the disk
Growing a filenot supportedappend, allocating blocks as needed

The two most important of those are the last two but one.

A journal. Creating a file changes three things: the bitmap, the directory and the data. If the machine loses power between the first and the second, the disk is inconsistent: a block is marked used and no file owns it. A journalling file system writes what it is about to do into a log first, so that after a crash the log can be replayed or undone. ext4 does this, which is why a Linux machine that loses power usually comes back in seconds instead of running a long check.

The cache. Every read in this program goes to the array. In a real system it goes to the page cache in memory first, and reaches the disk only if it is not there. That is why reading the same file twice is far faster the second time, and it is the reason the page replacement algorithms of [Practical 9: Memory Management, FIFO and LRU Page Replacement] apply to file data as well as to program memory.

munotes.in142

Practical 10 continued: a Simple File System

Procedure

  1. Write the program. Compile with gcc -Wall -Wextra -o fs fs.c and run it.
  2. Follow the bitmap through the run and write down which blocks each file holds.
  3. Work out the internal fragmentation for each of the four files: blocks times 16, minus the

size.

  1. Change the rounding to len / BLOCK_SIZE and run it again. Read the end of notes.txt and see

what was lost.

  1. Create files until the directory is full, and then until the disk is full, and check that both

messages appear and that no blocks are leaked. Print the bitmap afterwards to be sure.

  1. Add a write operation that appends to an existing file, allocating another block when the

last one is full.

  1. Add a second directory, so that a file has a path of two parts. That is the step from a flat

file system to a tree.

Result

A file system was designed and implemented over a simulated disk of 32 blocks of 16 bytes, with a free space bitmap, a flat directory of 8 entries, and indexed allocation of up to 6 blocks a file. Create, read, list and delete were implemented, each with its error cases checked. A 38 byte file was seen to occupy three 16 byte blocks with 10 bytes of internal fragmentation, and a file created after a deletion was seen to reuse the freed block and two others that were not adjacent to it, which is indexed allocation doing what contiguous allocation cannot.

Where marks are lost

  • Integer division for the block count. 38 / 16 is 2 and the last bytes of every file are

lost. It must round up.

  • Allocating block 0, which in this design holds the directory.
  • Not freeing the blocks on delete, so the disk fills with blocks nothing owns.
  • Not rolling back a failed create, which leaks blocks the same way.
  • No check for a duplicate name, so two directory entries claim the same name and only one can

ever be found.

  • Not checking that the file fits in the index. With six block numbers the largest file is 96

bytes here, and a program that writes a seventh number writes past the end of the array.

  • Printing the whole last block on read, including the bytes after the end of the file. The
munotes.in143

Practical 10 continued: a Simple File System

size in the directory is what says where the file stops.

  • Confusing internal with external fragmentation. Internal is the unused tail of the last

block; external is free space broken into pieces too small to use, which indexed allocation does not suffer from.

For the journal

Write the aim, MU's own wording, and the design first: the block size, the number of blocks, the layout of a directory entry, and which block holds the directory. Then the program, and the whole run with the bitmap after every operation, because the bitmap is the evidence that the allocation works. Add the internal fragmentation for each file as a subtraction, and one sentence on why new.txt got blocks 4, 7 and 8 rather than three consecutive ones. The conclusion: a file system is a free space map, a directory and a rule for recording which blocks a file owns, and indexed allocation lets a file be scattered so that no free space is wasted.

Quick revision

  • To a file system a disk is a numbered sequence of fixed-size blocks.
  • Four parts: the data blocks, the free space map, the directory, and the metadata about the disk

itself, which in Unix is the superblock.

  • Contiguous allocation: fast, cannot grow, external fragmentation. Linked: grows freely, no

direct access. Indexed: an index block per file, direct access and growth, at the cost of the index.

  • Linux uses indexed allocation and calls the index the inode: twelve direct block numbers

then three levels of indirection.

  • Free space is tracked by a bitmap, one bit a block, or by a free list threaded through the free

blocks themselves. A bitmap can find consecutive blocks; a free list cannot.

  • The block count rounds up: (len + BLOCK_SIZE - 1) / BLOCK_SIZE.
  • Internal fragmentation is the unused tail of the last block, and is unavoidable. External

fragmentation is free space broken into unusable pieces, and indexed allocation avoids it.

  • A create that fails halfway must give back the blocks it has already taken.
  • A directory entry holds a name and an inode number; the inode holds everything else. That split

is what makes hard links possible, and why the system call is unlink.

  • Deleting a file frees its blocks and usually does not wipe them, which is why deleted files can

often be recovered.

  • A real file system adds a tree of directories, permissions, times, a journal against crashes and

a page cache.

Questions you should be able to answer

1. How many 16 byte blocks does a 38 byte file need, and how is that computed? Three. Add BLOCK_SIZE - 1 to the length first: 38 + 15 is 53, and C's integer division of 53 by 16 gives 3. Plain 38 / 16 gives 2 and loses the last six bytes.

munotes.in144

Practical 10 continued: a Simple File System

2. What is internal fragmentation, and how much is there in that file? The unused part of the last block. Three blocks of 16 bytes is 48, the file is 38, so 48 - 38 = 10 bytes are wasted.

3. Give the three allocation methods and one disadvantage of each. Contiguous: a file cannot grow and free space suffers external fragmentation. Linked: there is no direct access to the middle of a file, and one damaged block loses the rest. Indexed: the index block is overhead and limits how large a file can be.

4. Which does Linux use, and what is its index called? Indexed allocation. The index is the inode, which holds twelve direct block numbers and then single, double and triple indirect blocks.

5. Why did the file created after a deletion get blocks 4, 7 and 8? Because block 4 had just been freed and was the first free block, and 7 and 8 were the next. Under indexed allocation the blocks need not be adjacent, so a single freed block in the middle of the disk is usable.

6. What is the difference between a bitmap and a free list? A bitmap is one bit per block, costs space proportional to the disk and can find consecutive free blocks quickly. A free list threads the free blocks together, costs no extra space and cannot find consecutive blocks without walking it.

7. What must a create do if the disk fills up halfway through? Free the blocks it has already allocated before reporting the failure, or they are leaked: marked used with no file owning them.

8. Why is the system call to remove a file called unlink? Because it removes one name from one directory. An inode may have several names, and its blocks are freed only when the last one goes.

9. What does a journalling file system protect against? An interrupted write leaving the disk inconsistent, for example a block marked used with no file owning it. The intended change is written to a log first, so it can be replayed or undone after a crash.

Contents This chapter on its own page

munotes.in145

Module II

Data Structures: ten exercises in Python

munotes.in

Chapter Seventeen

Python for Data Structures: the Tools This Module Uses

Syllabus topic Module 2, "Practical based on Data Structures", and her Text Book for it, Aho, Ullman and Lam, Data Structures and Algorithms in Python

Aim

To set up the tools Module 2 uses: the class, the reference, None, a readable __repr__, raising an exception instead of returning a sentinel, and measuring how long something takes.

What you need to know before you start

MU's Text Book for this module is Aho, Ullman and Lam, Data Structures and Algorithms in Python, and one of her two Reference Books is Kanetkar, Data Structures Through Python. This course also teaches Python in Semester 1. So Module 2 is written in Python here, and the ten exercises build each structure from scratch.

From scratch is the point. Python already has a list, a dictionary, a set and a deque, and every exercise in this module could be done in three lines with them. The examination is not asking for three lines. It is asking you to build the structure so that you know what the three lines are doing, and an answer that uses list.insert for the singly linked list exercise gets no marks.

MU's other Reference Book is Goodrich's Java edition, and MU writes "e.g., pthreads or Java threads" in Module 1, so a college may run this module in Java instead. If yours does, everything in these chapters is the same except the syntax: a Python class with __init__ is a Java class with a constructor, None is null, and a list of nodes is a list of nodes.

Running a program, and saving it for the journal

Three ways, and the second is the one to use.

HowCommandGood for
The interactive promptpython3trying one line
A filepython3 dsa.pyeverything in this module
Inside an editorIDLE, or VS Codewhile you are writing it

In the laboratory:

nano stack.py
python3 stack.py

To save a run for the journal, send the output to a file as well as the screen:

python3 stack.py | tee stack.out

tee writes to the file and to the screen at the same time, so you can see the run and paste it into the journal afterwards.

The class, which is how a structure is declared

Every structure in this module is one or two classes. A node and a container: the node holds one item and the link to the next, and the container holds the ends and the operations.

class Node:
    """One item of a linked structure."""

    def __init__(self, data, nxt=None):
        self.data = data
        self.nxt = nxt


n = Node(10)
m = Node(20)
n.nxt = m

print(n.data, n.nxt.data)
print(m.nxt)
10 20
None

Four things in nine lines, and all four come up in every chapter of this module.

__init__ is the constructor. It runs when Node(10) is written, and its job is to put the starting values into the new object. The name has two underscores on each side, which in Python marks a method the language itself calls.

munotes.in146

Python for Data Structures: the Tools This Module Uses

self is the object being worked on, and it is the first parameter of every method. It is not optional and it is not passed at the call: Node(10) calls __init__(new_object, 10).

nxt=None is a default. Node(10) and Node(10, None) mean the same thing, so a node made without a successor has one anyway: None.

The field is called nxt and not next on purpose. next is a built-in function in Python, and a field called next shadows nothing dangerous inside a class, but a local variable called next in a traversal does. Using nxt everywhere avoids having to remember which is safe. Some books write next and some write link; all three mean the same thing and an examiner will accept any of them.

None, and what it means in a structure

None is Python's single "there is nothing here" value. In this module it means three different things, and telling them apart is the whole of getting a linked structure right.

WhereWhat None means
head is Nonethe list is empty
node.nxt is Nonethis node is the last one
tree.left is Nonethis node has no left child
class Node:
    def __init__(self, data, nxt=None):
        self.data = data
        self.nxt = nxt


head = None
print("empty?", head is None)

head = Node(1, Node(2, Node(3)))

count = 0
here = head
while here is not None:
    count += 1
    here = here.nxt

print("nodes:", count)
print("last node holds:", 3, "and its nxt is", head.nxt.nxt.nxt)
empty? True
nodes: 3
last node holds: 3 and its nxt is None

while here is not None is the traversal, and it appears in nine of the ten exercises. Read it as "while I am still standing on a node". The three lines of that loop are worth learning by heart:

here = head
while here is not None:
    ...
    here = here.nxt

Forgetting here = here.nxt gives an infinite loop, and it is the single commonest mistake in this module. If a program in the examination hall never finishes, that line is where to look.

Use is None, not == None. is asks whether it is the same object, which is what is meant; == calls a comparison method, which a class of your own may define and get wrong. Python itself warns about == None in some tools, and it is the house style everywhere.

The reference, which is why a node can be shared

This is the idea that makes every structure in this module work, and it is worth one program of its own.

munotes.in147

Python for Data Structures: the Tools This Module Uses

class Node:
    def __init__(self, data, nxt=None):
        self.data = data
        self.nxt = nxt


a = Node("first")
b = a                      # NOT a copy: another name for the same node

b.data = "changed"
print("a.data is now", a.data)
print("are they the same object?", a is b)

c = Node("first")
print("c looks like a, but is it a?", a is c, "and equal?", a.data == c.data)
a.data is now changed
are they the same object? True
c looks like a, but is it a? False and equal? False

A variable holds a reference, not an object. b = a makes a second name for one node, so a change through either name is seen through both. That is why head = node.nxt moves a pointer and copies nothing, and why a linked list of a million nodes can be traversed without copying anything.

The last line is worth a second look. a.data was changed to "changed" before c was made, so a.data == c.data is False. If the change had not happened they would be equal in content and still not the same object, and is and == would disagree. is asks about identity and == asks about content, and every chapter in this module needs both: is None for the end of a list, == for searching.

A readable __repr__, which saves hours of debugging

Print a node without help and this is what you get:

class Bare:
    def __init__(self, data):
        self.data = data


class Node:
    def __init__(self, data, nxt=None):
        self.data = data
        self.nxt = nxt

    def __repr__(self):
        return f"Node({self.data!r})"


print(str(Bare(5)).split(" at ")[0] + " at 0x...>")
print(Node(5))
print([Node(1), Node(2), Node(3)])
<__main__.Bare object at 0x...>
Node(5)
[Node(1), Node(2), Node(3)]

The first line is what a class without __repr__ prints: its name and its address, which tells you nothing and is different on every run. Every class in this module defines __repr__, and the last line is why: printing a list of nodes then shows the nodes.

MethodCalled byShould give
__repr__repr(x), the prompt, and printing a list of themsomething a programmer can read, ideally valid Python
__str__print(x), str(x), f-stringssomething a user can read

If a class defines only __repr__, print falls back on it, which is why one method is enough for this module. !r inside an f-string means "use repr on this", which is what puts the quotation marks round a string and leaves a number bare.

Raising an exception rather than returning a sentinel

Every structure in this module has operations that can fail: popping an empty stack, dequeueing an empty queue, deleting a key that is not there. There are two ways to report it and only one is right.

munotes.in148

Python for Data Structures: the Tools This Module Uses

class Stack:
    def __init__(self):
        self.items = []

    def push(self, x):
        self.items.append(x)

    def pop(self):
        if not self.items:
            raise IndexError("pop from an empty stack")
        return self.items.pop()


s = Stack()
s.push(7)
print("popped", s.pop())

try:
    s.pop()
except IndexError as e:
    print("the second pop said:", e)
popped 7
the second pop said: pop from an empty stack

Returning None or -1 for an empty stack is the wrong answer, and the reason is short: None is a value somebody may legitimately have pushed. A stack holding None and an empty stack would then be indistinguishable, and the caller has no way to tell a failure from a success.

An exception cannot be mistaken for a value. It also cannot be ignored: a caller who forgets to check a returned -1 carries on with a wrong number, while a caller who forgets to catch an exception gets a stack trace that names the line.

The exceptions this module uses, and what each is for:

ExceptionRaise it when
IndexErrora position does not exist: popping an empty stack, index past the end
KeyErrora key is not present: deleting from a hash table or a tree
ValueErrorthe value is the wrong sort of thing: a negative size
TypeErrorthe argument is the wrong type altogether

Those are the same ones Python's own containers raise, which is the point: a structure of yours should fail the way a list or a dictionary fails, so that nobody has to learn your conventions.

try:
    [].pop()
except IndexError as e:
    print("list.pop on empty:", e)

try:
    {}["missing"]
except KeyError as e:
    print("dict['missing']:", e)

try:
    (1, 2)[5]
except IndexError as e:
    print("tuple[5]:", e)
list.pop on empty: pop from empty list
dict['missing']: 'missing'
tuple[5]: tuple index out of range

Those three messages are identical on Python 3.12, 3.13 and 3.14, which is how this page can print them. Some of Python's messages have changed between versions, and where a chapter in this module prints one, it has been run on all three.

The list against the node chain

Python's list is not an array and not a linked list. It is a dynamic array: a block of references that is reallocated larger when it fills up. Knowing that decides which operations are cheap.

items = [10, 20, 30]

items.append(40)          # cheap: put it at the end
items.insert(0, 5)        # dear: everything moves up one
print(items)

print("item 2 is", items[2])   # cheap: arithmetic on the address
items.pop(0)                   # dear: everything moves down one
print(items)
[5, 10, 20, 30, 40]
item 2 is 20
[10, 20, 30, 40]
OperationPython listSingly linked list
Read item ncheap, one stepdear, walk n nodes
Add at the endcheapdear, unless a tail is kept
Add at the frontdear, everything movescheap, one new node
Delete at the frontdearcheap
Memory for n itemsn references, plus spare roomn items plus n links
Growsby reallocating a bigger blockone node at a time
munotes.in149

Python for Data Structures: the Tools This Module Uses

That table is the answer to MU's bullet about static against dynamic, and it is measured rather than asserted in [Practical 12: Singly Linked Lists].

The cost of "walk n nodes" and "everything moves" is written O(n) and read "order n": the work grows in proportion to the number of items. "One step" is O(1), constant time, meaning the work does not depend on how many items there are. Those two symbols and O(log n) for a balanced tree are all the notation this module needs.

Measuring how long something takes

Several exercises ask for two approaches to be compared, so the measurement has to be trustworthy.

from time import perf_counter

n = 20000

start = perf_counter()
front = []
for i in range(n):
    front.insert(0, i)          # at the front: everything moves
at_front = perf_counter() - start

start = perf_counter()
back = []
for i in range(n):
    back.append(i)              # at the end
at_back = perf_counter() - start

print(f"{n} inserts at the front: {at_front:.4f} seconds")
print(f"{n} appends at the end  : {at_back:.4f} seconds")
print(f"the front was {at_front / at_back:.0f} times slower")
print("and both built the same list:", front == list(reversed(back)))
20000 inserts at the front: 0.1795 seconds
20000 appends at the end  : 0.0018 seconds
the front was 99 times slower
and both built the same list: True

Two rules about measuring, both learned the hard way in Module 1 of this book.

Print the answer, or the compiler and the interpreter may skip the work. The last line of that program compares the two lists, so neither loop can be optimised away and the reader can see that both really did build the same thing.

One measurement is not a result. The figures above are from one run on one machine and the ratio changes from run to run; what is stable is that inserting at the front of a list is far slower than appending, and that the gap grows with n. Where a chapter in this module prints a time, the timing digits are marked as varying and it is the shape that is proved, never the exact number.

perf_counter is the right clock for this: it is monotonic, it has the finest resolution the machine offers, and it measures elapsed time rather than processor time. time.time is a wall clock that can go backwards when the machine syncs its clock, and should not be used for measuring.

munotes.in150

Python for Data Structures: the Tools This Module Uses

For a small operation that takes microseconds, the standard library has a better tool:

import timeit

setup = "items = list(range(1000))"

front = timeit.timeit("items.insert(0, 99)", setup=setup, number=1000)
back = timeit.timeit("items.append(99)", setup=setup, number=1000)

print(f"1000 inserts at the front: {front:.5f} seconds")
print(f"1000 appends at the end  : {back:.5f} seconds")
1000 inserts at the front: 0.00253 seconds
1000 appends at the end  : 0.00005 seconds

timeit runs the statement many times, uses the best clock available and turns the garbage collector off, so it is the tool to reach for when the thing being measured is fast.

The shape of every chapter in Module 2

Each of MU's ten exercises is one journal entry and one chapter, laid out the same way:

  1. Aim, in MU's own words.
  2. What you need to know: the structure explained before any code, with a picture in text.
  3. The class, built up operation by operation, with the reasoning for each.
  4. The run, which is what the program actually printed on all three interpreters.
  5. The cost of each operation, as a table, because the examination asks for it.
  6. Where it is used, which is MU's own third bullet in most of the exercises.
  7. Procedure, Result, Where marks are lost, For the journal, Quick revision

and Questions you should be able to answer.

Procedure

  1. Check your Python version with python3 --version. Anything from 3.8 onwards runs everything

in this module.

  1. Type the Node program into a file and run it. Add a fourth node and count again.
  2. Delete the line here = here.nxt and run it. Stop the program with Ctrl+C and note the

message, because you will see it again.

  1. Add __repr__ to a class of your own and print a list of three of them.
  2. Write a Stack whose pop returns None when empty, push None on to it, and satisfy

yourself that the caller cannot tell the two cases apart.

  1. Run the timing program with n of 10000, 20000 and 40000, and note that the ratio grows.

Result

The tools Module 2 uses were set up and tried: a class with __init__ and self, None as the end of a structure, the reference that lets two names share one node, __repr__ for a readable printout, an exception rather than a sentinel for a failed operation, and perf_counter and timeit for measuring. Inserting at the front of a Python list was measured to be many times slower than appending, which is the fact the linked list exercise rests on.

Where marks are lost

  • Using Python's own list, dictionary or deque for an exercise that asks for the structure to be built.
munotes.in151

Python for Data Structures: the Tools This Module Uses

No marks, however correct the answer.

  • Forgetting here = here.nxt, so the traversal never ends.
  • == None instead of is None.
  • No __repr__, so the output is a list of memory addresses and the examiner cannot see what

the program built.

  • Returning None or -1 from a failed operation instead of raising an exception.
  • Forgetting self in a method's parameter list, which gives a TypeError naming the wrong

number of arguments.

  • Claiming a speed difference from one run without saying it was one run.

For the journal

This chapter is groundwork rather than one of the twenty, so it needs no journal entry of its own unless your teacher asks for one. If they do: the Node program, the traversal, the __repr__ before and after, and the timing figures with your own machine's numbers.

Quick revision

  • A structure is a node class and a container class. __init__ is the constructor and

self is the object.

  • None means empty list, last node, or missing child, depending on where it is. Test it with

is None.

  • The traversal is here = head, while here is not None, and here = here.nxt at the bottom.

Leaving the last line out is an infinite loop.

  • A variable holds a reference. b = a is a second name for one object, not a copy. is asks

about identity, == about content.

  • Every class defines __repr__, or printing a list of nodes prints addresses.
  • A failed operation raises: IndexError for a position, KeyError for a key, ValueError

for a bad value. Returning None is wrong because None may be a real item.

  • Python's list is a dynamic array: cheap at the end, dear at the front. A linked list is the

opposite.

  • O(1) means the work does not grow with the number of items; O(n) means it grows in proportion.
  • Measure with perf_counter for anything slow and timeit for anything fast, never time.time,

and print the answer so the work cannot be skipped.

  • Every listing in this module was run on Python 3.14, 3.13 and 3.12 and printed the same thing on

all three.

Questions you should be able to answer

1. What is self, and why is it the first parameter of every method? It is the object the method was called on. Python passes it automatically, so s.push(7) calls push(s, 7), and a method written without it fails with a TypeError about the number of arguments.

2. What does None mean in a linked list? Three things depending on where it is: an empty list when the head is None, the last node when a node's nxt is None, and a missing child when a tree node's left or right is None.

munotes.in152

Python for Data Structures: the Tools This Module Uses

3. Why is None rather than == None? Because is compares identity, which is what is meant, while == calls a comparison method that a class of your own may define and get wrong.

4. b = a where a is a node. How many nodes are there? One. b is a second name for the same object, so a change through either name is visible through both.

5. Why does every class in this module define __repr__? Because without it, printing an object gives its class name and its address, which is different on every run and says nothing. With it, printing a list of nodes shows what the nodes hold.

6. Why should pop on an empty stack raise rather than return None? Because None is a value somebody may have pushed, so the caller could not tell an empty stack from a stack holding None. An exception cannot be mistaken for a value and cannot be silently ignored.

7. Which is cheaper in a Python list, append or insert(0, x), and why? append. A Python list is a dynamic array, so inserting at the front moves every other item up one, while appending usually writes into spare room at the end. Measured here, 20000 inserts at the front took about eighty times as long as 20000 appends.

8. What does O(1) mean, and what does O(n) mean? O(1) means the work does not depend on how many items there are. O(n) means it grows in proportion to the number of items.

9. Which clock should be used for timing, and which should not? time.perf_counter for anything slow and timeit for anything fast. Not time.time, which is a wall clock and can move backwards when the machine adjusts it.

Contents This chapter on its own page

munotes.in153

Chapter Eighteen

Practical 11: Abstract Data Types and Custom Structures

Syllabus topic Module 2, "Exploring Abstract Data Types (ADT) and Custom Structures: Create and manipulate structures to model ADTs like Student, Book, or Employee. Implement basic operations (create, update, delete) using structures. Reflect on differences between primitive and abstract data types."

Aim

To model the abstract data types Student, Book and Employee as structures, to implement create, update and delete over a collection of them, and to state the difference between a primitive and an abstract data type.

What you need to know before you start

An abstract data type, or ADT, is a description of what a type does, with nothing said about how. It has two halves:

  • The values it can hold. A Stack holds a sequence of items.
  • The operations on it, with what each one takes, what it gives back, and what must be true

before and after. A Stack has push, pop, peek and is_empty, and pop on an empty Stack is an error.

That is all. An ADT does not say whether the Stack is an array or a linked list, and that is the point: the description is the contract, and the implementation is anybody's business. A program written against the contract keeps working when the implementation is replaced.

A data structure is the other half: a particular way of laying the values out in memory, with the operations written to suit it. So:

Abstract data typeData structure
Sayswhat operations exist and what they meanhow the values are stored
ExampleStack, Queue, List, Tree, Maparray, linked list, hash table, heap
Written inwords, or a class with no bodiescode
Who caresthe person using itthe person implementing it
Can change without breaking callersnoyes

One ADT can have many structures. A Stack over an array and a Stack over a linked list are the same ADT and different data structures, and [Practical 15: the Stack ADT] builds both.

Primitive against abstract, which is MU's third bullet

Primitive typeAbstract data type
Examplesint, float, bool, charStudent, Book, Stack, Tree
Provided bythe languageyou
How it is storedthe language decides, and it is one machine wordyou decide
What you may doa fixed set: add, compare, and so onwhatever operations you declare
Holdsone valueseveral values, and behaviour
Can it enforce a ruleno; an int will hold -5 marksyes; a Student can refuse them

That last row is the one to remember, and the whole reason this exercise exists. An int cannot protect itself. A type of your own can.

MU's three ADTs: Student, three ways

The same information, held three ways, so that the choice can be argued about rather than assumed.

# three ways to model one ADT
student_tuple = (2331, "Aarti Kulkarni", [78, 65, 92])
print("tuple      :", student_tuple)
print("  the name is student_tuple[1]:", student_tuple[1])

student_dict = {"roll": 2331, "name": "Aarti Kulkarni", "marks": [78, 65, 92]}
print("dictionary :", student_dict)
print("  the name is student_dict['name']:", student_dict["name"])


class Student:
    """A student: the roll number, the name and three marks."""

    def __init__(self, roll, name, marks):
        self.roll = roll
        self.name = name
        self.marks = marks

    def total(self):
        return sum(self.marks)

    def average(self):
        return self.total() / len(self.marks)

    def grade(self):
        a = self.average()
        if a >= 80:
            return "distinction"
        if a >= 60:
            return "first class"
        if a >= 40:
            return "pass"
        return "fail"

    def __repr__(self):
        return f"Student({self.roll}, {self.name!r}, {self.marks!r})"


s = Student(2331, "Aarti Kulkarni", [78, 65, 92])
print("class      :", s)
print("  the name is s.name:", s.name)
print("  and it can compute: total", s.total(),
      "average", f"{s.average():.2f}", "grade", s.grade())
munotes.in154

Practical 11: Abstract Data Types and Custom Structures

tuple      : (2331, 'Aarti Kulkarni', [78, 65, 92])
  the name is student_tuple[1]: Aarti Kulkarni
dictionary : {'roll': 2331, 'name': 'Aarti Kulkarni', 'marks': [78, 65, 92]}
  the name is student_dict['name']: Aarti Kulkarni
class      : Student(2331, 'Aarti Kulkarni', [78, 65, 92])
  the name is s.name: Aarti Kulkarni
  and it can compute: total 235 average 78.33 grade first class

The tuple is the smallest and the least readable. student_tuple[1] is the name, and nothing in the program says so. Change the order of the fields and every use of it is silently wrong. A tuple is also immutable: the marks list inside it can be changed, but the roll number cannot, which is sometimes exactly right and sometimes a nuisance.

The dictionary is readable and unguarded. student_dict["name"] says what it is, which is a real improvement. But nothing stops student_dict["nmae"] = "x" from quietly adding a fourth key, nothing requires a student to have marks at all, and the dictionary cannot compute anything: the total has to be worked out by whoever wants it, everywhere.

The class is the ADT. The fields have names, the operations live with the data, and the rules can be enforced. s.grade() is written once and used everywhere; sum(student_dict["marks"]) is written everywhere and gets changed in one place out of four.

Use a tuple for a value with two or three parts that never changes, such as a coordinate. Use a

dictionary when the keys are not known until the program runs, such as data read from a file.

Use a class when the thing has rules or behaviour, which in this module it always does.

Python has a shorthand that gives a class most of its boilerplate for nothing:

from dataclasses import dataclass

@dataclass
class Student:
    roll: int
    name: str
    marks: list

That writes __init__, __repr__ and __eq__ for you. It is worth knowing and it is not used in this module, because the exercises are about writing the structure, and an examiner asking for a Student ADT wants to see __init__.

munotes.in155

Practical 11: Abstract Data Types and Custom Structures

Create, update and delete, which need a collection

MU's second bullet asks for the basic operations, and they belong not to one object but to a collection of them. One Book cannot be created and deleted; a catalogue of Books can.

The four operations together are called CRUD: create, read, update, delete.

class Book:
    """A book in a library: the ADT and nothing but the ADT."""

    def __init__(self, isbn, title, author, copies):
        if copies < 0:
            raise ValueError("a book cannot have a negative number of copies")
        self.isbn = isbn
        self.title = title
        self.author = author
        self.copies = copies
        self.out = 0                      # how many are lent out

    def available(self):
        return self.copies - self.out

    def lend(self):
        if self.available() == 0:
            raise ValueError(f"no copy of {self.title!r} is available")
        self.out += 1

    def take_back(self):
        if self.out == 0:
            raise ValueError(f"no copy of {self.title!r} is out")
        self.out -= 1

    def __repr__(self):
        return (f"Book({self.isbn!r}, {self.title!r}, {self.author!r}, "
                f"{self.copies})")


class Library:
    """A collection of Books, with create, read, update and delete."""

    def __init__(self):
        self.books = {}                   # isbn -> Book

    def create(self, isbn, title, author, copies):
        if isbn in self.books:
            raise KeyError(f"{isbn} is already in the catalogue")
        self.books[isbn] = Book(isbn, title, author, copies)
        return self.books[isbn]

    def read(self, isbn):
        if isbn not in self.books:
            raise KeyError(f"{isbn} is not in the catalogue")
        return self.books[isbn]

    def update(self, isbn, **changes):
        book = self.read(isbn)
        for field, value in changes.items():
            if not hasattr(book, field):
                raise KeyError(f"a Book has no field called {field!r}")
            setattr(book, field, value)
        return book

    def delete(self, isbn):
        book = self.read(isbn)
        if book.out:
            raise ValueError(f"{book.title!r} has {book.out} copy out")
        del self.books[isbn]
        return book

    def __len__(self):
        return len(self.books)

    def report(self):
        print(f"  {'isbn':<14}{'title':<32}{'copies':>7}{'out':>5}{'free':>6}")
        for isbn in sorted(self.books):
            b = self.books[isbn]
            print(f"  {b.isbn:<14}{b.title:<32}{b.copies:>7}{b.out:>5}"
                  f"{b.available():>6}")
        print(f"  {len(self)} title(s) in the catalogue")


lib = Library()
lib.create("978-0132126", "Operating System Concepts", "Silberschatz", 3)
lib.create("978-1118290", "Data Structures in Python", "Goodrich", 2)
lib.create("978-8183332", "Data Structures Through Python", "Kanetkar", 1)
print("after three creates")
lib.report()

print()
print("read  : ", lib.read("978-1118290"))
lib.update("978-1118290", copies=5)
print("update: copies of Goodrich now", lib.read("978-1118290").copies)

lib.read("978-8183332").lend()
print("lend  : Kanetkar out", lib.read("978-8183332").out,
      "available", lib.read("978-8183332").available())

print()
for isbn, what in (("978-8183332", "delete a book that is lent out"),
                   ("978-0000000", "read a book that is not there")):
    try:
        lib.delete(isbn)
    except (KeyError, ValueError) as e:
        print(f"{what}: {type(e).__name__}: {e}")

try:
    lib.create("978-0132126", "Operating System Concepts", "Silberschatz", 1)
except KeyError as e:
    print(f"create a book twice: KeyError: {e}")

try:
    Book("978-1", "Nothing", "Nobody", -2)
except ValueError as e:
    print(f"a negative number of copies: ValueError: {e}")

print()
lib.read("978-8183332").take_back()
lib.delete("978-8183332")
print("after taking the copy back and deleting")
lib.report()
after three creates
  isbn          title                            copies  out  free
  978-0132126   Operating System Concepts             3    0     3
  978-1118290   Data Structures in Python             2    0     2
  978-8183332   Data Structures Through Python        1    0     1
  3 title(s) in the catalogue

read  :  Book('978-1118290', 'Data Structures in Python', 'Goodrich', 2)
update: copies of Goodrich now 5
lend  : Kanetkar out 1 available 0

delete a book that is lent out: ValueError: 'Data Structures Through Python' has 1 copy out
read a book that is not there: KeyError: '978-0000000 is not in the catalogue'
create a book twice: KeyError: '978-0132126 is already in the catalogue'
a negative number of copies: ValueError: a book cannot have a negative number of copies

after taking the copy back and deleting
  isbn          title                            copies  out  free
  978-0132126   Operating System Concepts             3    0     3
  978-1118290   Data Structures in Python             5    0     5
  2 title(s) in the catalogue
munotes.in156

Practical 11: Abstract Data Types and Custom Structures

What that program is careful about

Every operation that can fail, checks, and raises. There are five such checks and the output shows four of them firing:

OperationThe checkThe exception
createthe ISBN is already in the catalogueKeyError
readthe ISBN is not in the catalogueKeyError
updatea field name that a Book does not haveKeyError
deletea copy of the book is lent outValueError
Book itselfa negative number of copiesValueError

The rule from [Python for Data Structures: the Tools This Module Uses] is being followed: a failed operation raises and does not return None. And the exception type carries meaning: KeyError for something not present, ValueError for something present and wrong.

update refuses a field that does not exist. hasattr(book, field) is the guard, and without it lib.update(isbn, copyes=5) would add a new attribute called copyes and the real copies would be untouched. The program would print no error and give the wrong answer, which is the dictionary's problem from the last section appearing in a class.

delete returns the book it removed. A delete that returns the deleted object lets a caller undo it, which is how the undo of [Practical 14: Doubly Linked Lists] works.

__len__ makes len(lib) work, which is the first of Python's protocols this module uses. A class that defines __len__ can be measured with len() and is also truthy when non-empty, so if lib: works. __repr__ and __len__ are the two every container in this module defines.

The catalogue is a dictionary keyed by ISBN, so read is one step whatever the size. A list of Books would have to be searched, which is O(n). That is a data structure decision inside the ADT, and no caller can tell: swap the dictionary for a hash table of your own from [Practical 20: Hashing and Collision Handling] and every line outside the class still works. That is what "abstract" means, demonstrated.

What abstraction actually buys

The Employee, MU's third named ADT, is where the benefit is easiest to see.

munotes.in157

Practical 11: Abstract Data Types and Custom Structures

# a primitive type: what it is and what you may do with it are fixed
n = 42
print("an int:", n, "of type", type(n).__name__)
print("  everything it can do is built in:", n + 1, n * 2, n ** 2)

# an abstract data type: WHAT it does is declared, HOW is hidden
class Employee:
    """Name, basic pay and a rule for the allowance. The rule is hidden."""

    HRA_RATE = 0.20
    DA_RATE = 0.10

    def __init__(self, name, basic):
        self.name = name
        self._basic = basic          # the single underscore says: not yours

    def basic(self):
        return self._basic

    def allowances(self):
        return self._basic * (Employee.HRA_RATE + Employee.DA_RATE)

    def gross(self):
        return self._basic + self.allowances()

    def raise_by(self, per_cent):
        if per_cent < 0:
            raise ValueError("a raise cannot be negative")
        self._basic = self._basic * (1 + per_cent / 100)

    def __repr__(self):
        return f"Employee({self.name!r}, {self._basic:.2f})"


e = Employee("R. Deshmukh", 30000)
print()
print("an Employee:", e)
print(f"  basic {e.basic():.2f}, allowances {e.allowances():.2f}, "
      f"gross {e.gross():.2f}")
e.raise_by(10)
print("after a 10 per cent raise:", e)
print(f"  gross is now {e.gross():.2f}")

print()
print("what makes it ABSTRACT:")
print("  the caller asks for gross() and never computes it")
print("  the two rates could become a table from a database")
print("  and not one line of the caller would change")
an int: 42 of type int
  everything it can do is built in: 43 84 1764

an Employee: Employee('R. Deshmukh', 30000.00)
  basic 30000.00, allowances 9000.00, gross 39000.00
after a 10 per cent raise: Employee('R. Deshmukh', 33000.00)
  gross is now 42900.00

what makes it ABSTRACT:
  the caller asks for gross() and never computes it
  the two rates could become a table from a database
  and not one line of the caller would change

_basic has one leading underscore. In Python that is a convention and not a lock: it means "this is not part of what I promise, do not touch it". Nothing stops a caller from writing e._basic = 0, and the underscore tells them that if they do, and it breaks later, that is their fault. Java would write private; Python writes an underscore and trusts you.

The caller never computes the gross pay. It asks. So when the allowance rule changes, and in India it changes every few years, one class changes and nothing else does. Had the caller been computing basic * 1.30 itself, in eleven places, the change would be eleven edits and one of them would be missed.

raise_by enforces a rule that an int cannot. A plain number for the basic pay would happily take a negative raise. The ADT refuses it.

Those three paragraphs are the answer to "what is the advantage of an abstract data type", and they are worth four marks:

  1. Encapsulation. The data and the operations on it are in one place, so a rule is written
munotes.in158

Practical 11: Abstract Data Types and Custom Structures

once.

  1. Information hiding. The representation is private, so it can change without breaking

callers.

  1. A validated state. The type can refuse a value that makes no sense, which a primitive

cannot.

  1. Reuse. The type can be used anywhere without being re-understood.

Procedure

  1. Write the three Students. Run it, then change the order of the tuple's fields and note that

student_tuple[1] now gives the wrong thing with no error.

  1. Add a passed() method to the class that returns True when every mark is 40 or more.
  2. Write the Library. Run it and check all four error messages appear.
  3. Remove the hasattr check from update, call lib.update(isbn, copyes=9), and print the

catalogue. Nothing complains and nothing changed.

  1. Replace the dictionary in Library with a list of Books and rewrite read to search it.

Confirm that no code outside the class changes.

  1. Write the Employee. Change HRA_RATE to 0.25 and note that only one line moved.
  2. Try e._basic = -100 and then e.gross(). The underscore is a convention, not a lock.

Result

The abstract data types Student, Book and Employee were modelled as classes. The Student was built as a tuple, a dictionary and a class and the three compared: only the class can name its fields, carry its own operations and enforce a rule. A Library of Books implemented create, read, update and delete over a collection, with five failure cases each raising the appropriate exception, and the representation inside it was shown to be replaceable without changing a line outside. The Employee showed the three benefits of abstraction: the allowance rule is written once, the representation is hidden behind an underscore, and a negative raise is refused.

Where marks are lost

  • Saying an ADT is a class. A class is one way of implementing one. The ADT is the description

of the operations and their meanings.

  • Not distinguishing the ADT from the data structure. A Stack is an ADT; an array is a data

structure; a Stack over an array is one implementation.

  • Only the data, with no operations. A class with __init__ and nothing else is a record, not

an ADT.

  • No validation. The whole answer to "why not just use an int" is that a type of your own can

refuse a wrong value.

  • Returning None from a failed create or delete instead of raising.
  • Using KeyError and ValueError interchangeably. Not present is a KeyError; present and

wrong is a ValueError.

  • No __repr__, so the examiner sees a list of memory addresses.
  • Writing update so that a misspelled field silently creates a new one.

For the journal

Write the aim, MU's own wording, and the definitions of abstract data type and data structure in your own words, because the first question is always one of those. Then the Student three ways with its output, and one sentence on which you would use and why. Then the Library program in full with its run, including the four error lines, because the failure cases are what distinguish a complete answer. Then the Employee and the four advantages of abstraction as a list. The conclusion: an ADT declares what a type does and hides how it does it, which is what lets the representation change and what lets the type enforce rules a primitive cannot.

munotes.in159

Practical 11: Abstract Data Types and Custom Structures

Quick revision

  • An abstract data type is the values and the operations, with the implementation unspecified.

A data structure is a way of laying the values out. One ADT, many structures.

  • Primitive types come from the language, hold one value, and cannot enforce a rule. An ADT is

yours, holds several values and behaviour, and can.

  • Tuple: small, ordered, immutable, unnamed fields. Dictionary: named keys, no rules, no

behaviour. Class: named fields, its own operations, and it can refuse a bad value.

  • CRUD is create, read, update, delete, and they belong to a collection, not to one object.
  • A failed operation raises. KeyError for not present, ValueError for present and wrong.
  • update checks with hasattr that the field exists, or a misspelling silently adds a new one.
  • A leading underscore means "not part of the promise". It is a convention, not a lock.
  • __repr__ for a readable printout, __len__ so that len() and truthiness work.
  • The four advantages: encapsulation, information hiding, a validated state, and reuse.
  • The Library's catalogue is a dictionary, and could be a list or a hash table, and no caller

would know. That is abstraction in one sentence.

Questions you should be able to answer

1. Define an abstract data type. A description of a type by the values it can hold and the operations on it, with what each operation takes, gives and requires, and with nothing said about how the values are stored.

2. What is the difference between an ADT and a data structure? The ADT is the contract: what the operations are and what they mean. The data structure is the implementation: how the values are laid out in memory. One ADT can be implemented by several structures.

3. Give three differences between a primitive type and an abstract data type. A primitive comes from the language and an ADT from you; a primitive holds one value and an ADT holds several plus behaviour; a primitive cannot refuse a meaningless value and an ADT can.

4. Why model a Student as a class rather than a dictionary? Because the class names its fields, so a misspelling is an error rather than a new key; it carries its own operations, so the total and the grade are written once; and it can enforce rules, such as refusing a negative mark.

munotes.in160

Practical 11: Abstract Data Types and Custom Structures

5. What are the four basic operations on a collection, and why do they not belong to one object? Create, read, update and delete. One Book cannot be created or deleted; the catalogue that holds it can.

6. lib.update(isbn, copyes=5) is called by mistake. What should happen, and what happens without a guard? It should raise a KeyError naming the unknown field. Without the hasattr guard it adds a new attribute called copyes, changes nothing that matters, and reports no error.

7. What does a single leading underscore on an attribute mean in Python? That it is not part of the class's promise and a caller should not use it. It is a convention: nothing prevents access, and a caller who relies on it has no complaint when it changes.

8. The Library keeps its books in a dictionary. What would change if it kept them in a list? Inside the class, read would have to search, so it would go from one step to O(n). Outside the class, nothing at all. That is what information hiding means.

9. Name the four advantages of using an abstract data type. Encapsulation, so the data and its operations are together; information hiding, so the representation can change; a validated state, so a meaningless value can be refused; and reuse, because the type can be used without being re-understood.

Contents This chapter on its own page

munotes.in161

Chapter Nineteen

Practical 12: Singly Linked Lists

Syllabus topic Module 2, "Building and Using Singly Linked Lists: Construct a dynamic singly linked list with basic operations. Apply linked lists to simulate scenarios such as managing a playlist or to-do list. Compare static (array) vs dynamic (linked) approaches."

Aim

To construct a dynamic singly linked list with its basic operations, to apply it to a playlist, and to compare the array with the linked list.

What you need to know before you start

A singly linked list is a chain of nodes. Each node holds one item and a reference to the next node, and the last node's reference is None. The list itself is just a reference to the first node, called the head.

head -> [ Physics | * ] -> [ Chemistry | * ] -> [ Maths | None ]

Nothing about that picture is contiguous. The three nodes may be anywhere in memory, and the arrows are what hold them together. That is the whole difference from an array, and everything else follows from it:

Array, or Python listSingly linked list
The items areside by side in memoryanywhere, joined by links
Item n is found byarithmetic on the address, one stepwalking n links
Adding at the frontmoves every other itemone new node
Adding at the backcheap, if there is roomone new node, if a tail is kept
Growingreallocate a bigger block and copynothing to do
Memory for n itemsn slots, plus spare roomn items plus n links
Going backwardsyes, subtract oneno

The last row is the defect that [Practical 14: Doubly Linked Lists] exists to cure.

The three things a list class must keep

  • head, the first node, or None when the list is empty.
  • tail, the last node. It is optional and it is worth having: without it, appending means

walking the whole list, which is O(n); with it, appending is one step.

  • count, how many nodes there are. Also optional, and also worth having, because otherwise

len() has to walk.

Keeping a tail and a count means every operation must maintain them, and forgetting one is the commonest bug in this exercise. Removing the last node must move the tail back; removing the only node must set both head and tail to None.

The class, with every operation

class Node:
    """One item of the list, and the link to the next one."""

    def __init__(self, data, nxt=None):
        self.data = data
        self.nxt = nxt

    def __repr__(self):
        return f"Node({self.data!r})"


class SinglyLinkedList:
    """The container: it owns the head, the tail and the count."""

    def __init__(self):
        self.head = None
        self.tail = None
        self.count = 0

    # ---- asking about it -------------------------------------------------
    def is_empty(self):
        return self.head is None

    def __len__(self):
        return self.count

    def __iter__(self):
        here = self.head
        while here is not None:
            yield here.data
            here = here.nxt

    def __repr__(self):
        return " -> ".join(repr(x) for x in self) + " -> None"

    # ---- putting things in -----------------------------------------------
    def prepend(self, data):
        """At the head. One step, whatever the length."""
        node = Node(data, self.head)
        self.head = node
        if self.tail is None:
            self.tail = node
        self.count += 1

    def append(self, data):
        """At the tail. One step too, BECAUSE a tail is kept."""
        node = Node(data)
        if self.tail is None:
            self.head = self.tail = node
        else:
            self.tail.nxt = node
            self.tail = node
        self.count += 1

    def insert_after(self, target, data):
        """After the first node holding `target`."""
        here = self.head
        while here is not None and here.data != target:
            here = here.nxt
        if here is None:
            raise ValueError(f"{target!r} is not in the list")
        node = Node(data, here.nxt)
        here.nxt = node
        if here is self.tail:
            self.tail = node
        self.count += 1

    # ---- finding things --------------------------------------------------
    def search(self, target):
        """The POSITION of the first match, counting from 0, or -1."""
        at = 0
        here = self.head
        while here is not None:
            if here.data == target:
                return at
            here = here.nxt
            at += 1
        return -1

    def __contains__(self, target):
        return self.search(target) != -1

    # ---- taking things out -----------------------------------------------
    def remove(self, target):
        """The first node holding `target`. The trailing pointer is the trick."""
        before = None
        here = self.head
        while here is not None and here.data != target:
            before = here
            here = here.nxt
        if here is None:
            raise ValueError(f"{target!r} is not in the list")
        if before is None:                 # it was the head
            self.head = here.nxt
        else:
            before.nxt = here.nxt
        if here is self.tail:              # it was the tail
            self.tail = before
        self.count -= 1
        return here.data

    def reverse(self):
        """Turn every link round, in one pass and with no new nodes."""
        before = None
        here = self.head
        self.tail = self.head
        while here is not None:
            after = here.nxt               # remember where we were going
            here.nxt = before              # turn this link round
            before = here                  # and step both pointers on
            here = after
        self.head = before


lst = SinglyLinkedList()
print("empty:", lst.is_empty(), "length", len(lst))

for name in ("Physics", "Chemistry", "Maths"):
    lst.append(name)
print("after three appends :", lst)

lst.prepend("English")
print("after one prepend   :", lst)

lst.insert_after("Chemistry", "Biology")
print("after insert_after  :", lst)

print()
print("length              :", len(lst))
print("search('Maths')     :", lst.search("Maths"))
print("search('Sanskrit')  :", lst.search("Sanskrit"))
print("'Biology' in lst    :", "Biology" in lst)
print("head                :", lst.head, " tail:", lst.tail)

print()
print("remove('English')   :", lst.remove("English"), "->", lst)
print("remove('Maths')     :", lst.remove("Maths"), "->", lst)
print("tail is now         :", lst.tail)

print()
lst.reverse()
print("after reverse       :", lst)
print("head", lst.head, "tail", lst.tail)

print()
try:
    lst.remove("Sanskrit")
except ValueError as e:
    print("remove something absent: ValueError:", e)
try:
    lst.insert_after("Sanskrit", "Hindi")
except ValueError as e:
    print("insert after absent    : ValueError:", e)
munotes.in162

Practical 12: Singly Linked Lists

empty: True length 0
after three appends : 'Physics' -> 'Chemistry' -> 'Maths' -> None
after one prepend   : 'English' -> 'Physics' -> 'Chemistry' -> 'Maths' -> None
after insert_after  : 'English' -> 'Physics' -> 'Chemistry' -> 'Biology' -> 'Maths' -> None

length              : 5
search('Maths')     : 4
search('Sanskrit')  : -1
'Biology' in lst    : True
head                : Node('English')  tail: Node('Maths')

remove('English')   : English -> 'Physics' -> 'Chemistry' -> 'Biology' -> 'Maths' -> None
remove('Maths')     : Maths -> 'Physics' -> 'Chemistry' -> 'Biology' -> None
tail is now         : Node('Biology')

after reverse       : 'Biology' -> 'Chemistry' -> 'Physics' -> None
head Node('Biology') tail Node('Physics')

remove something absent: ValueError: 'Sanskrit' is not in the list
insert after absent    : ValueError: 'Sanskrit' is not in the list
munotes.in163

Practical 12: Singly Linked Lists

The four operations worth reading twice

prepend is the cheap one. A new node whose nxt is the old head, and then the head is the new node. Two assignments, whatever the length of the list. The order matters: build the node pointing at the old head first, then move the head. Reversing those two lines loses the whole list.

append is cheap only because of the tail. Without self.tail it would have to walk to the end, and appending n items would cost n squared steps in total. The two cases are the empty list, where head and tail both become the new node, and everything else, where the old tail's nxt is set and then the tail moves.

remove needs a trailing pointer, and this is the idea to take away from the chapter:

before = None
here = self.head
while here is not None and here.data != target:
    before = here
    here = here.nxt

A singly linked node cannot reach its predecessor, so to unlink a node you must already be holding the one in front of it. before walks one step behind here for exactly that reason. Then three cases:

The node to remove wasWhat to do
the head, so before is Noneself.head = here.nxt
in the middlebefore.nxt = here.nxt
the tailalso self.tail = before

Note that the first and third can both be true, when the list had one node: then head becomes None and tail becomes before, which is None. The code handles it without a special case, which is worth checking on paper.

reverse turns every link round in one pass, and it is a favourite examination question:

before = None
here = self.head
while here is not None:
    after = here.nxt        # remember where we were going
    here.nxt = before       # turn this link round
    before = here           # step both pointers on
    here = after
self.head = before

Three pointers and no new nodes. after has to be saved before here.nxt is overwritten, or the rest of the list is lost; that single line is what the question is testing. The run above shows the head and the tail swapping over, which is the other half of getting it right.

munotes.in164

Practical 12: Singly Linked Lists

Making the list behave like a Python container

Three special methods, and each buys something:

MethodWhat it buys
__len__len(lst) works, and if lst: is False when empty
__iter__for x in lst: works, and so do list(lst), sum(lst), max(lst)
__contains__"Biology" in lst works
__repr__printing the list shows the items and the trailing None

__iter__ is written with yield, which makes it a generator: it produces one item at a time without building a list of them. That is why __repr__ can be written as one line over self, and why for name in lst: costs no extra memory however long the list is.

__contains__ is defined here in terms of search, so in and search can never disagree. Writing the traversal twice is how two methods come to answer differently.

MU's application: a playlist

MU names a playlist or a to-do list. A playlist is the better example, because it needs one thing an array makes awkward: a pointer to the item currently playing, which must survive songs being added and removed around it.

class Song:
    def __init__(self, title, minutes):
        self.title = title
        self.minutes = minutes

    def __repr__(self):
        return f"{self.title} ({self.minutes} min)"


class Node:
    def __init__(self, data, nxt=None):
        self.data = data
        self.nxt = nxt


class Playlist:
    """A singly linked list with a pointer to what is playing."""

    def __init__(self):
        self.head = None
        self.tail = None
        self.playing = None
        self.count = 0

    def add(self, song):
        node = Node(song)
        if self.tail is None:
            self.head = self.tail = node
            self.playing = node
        else:
            self.tail.nxt = node
            self.tail = node
        self.count += 1

    def play_next(self):
        """Move on. At the end, wrap round to the beginning."""
        if self.playing is None:
            raise IndexError("the playlist is empty")
        self.playing = self.playing.nxt if self.playing.nxt else self.head
        return self.playing.data

    def now_playing(self):
        if self.playing is None:
            raise IndexError("the playlist is empty")
        return self.playing.data

    def remove(self, title):
        before, here = None, self.head
        while here is not None and here.data.title != title:
            before, here = here, here.nxt
        if here is None:
            raise ValueError(f"{title!r} is not in the playlist")
        if before is None:
            self.head = here.nxt
        else:
            before.nxt = here.nxt
        if here is self.tail:
            self.tail = before
        if here is self.playing:               # what was playing has gone
            self.playing = here.nxt or self.head
        self.count -= 1

    def total_minutes(self):
        total, here = 0, self.head
        while here is not None:
            total += here.data.minutes
            here = here.nxt
        return total

    def show(self):
        here = self.head
        while here is not None:
            mark = " <- playing" if here is self.playing else ""
            print(f"    {here.data}{mark}")
            here = here.nxt
        print(f"    {self.count} song(s), {self.total_minutes()} minutes")


p = Playlist()
for title, mins in (("Ae Dil Hai Mushkil", 4),
                    ("Kun Faya Kun", 8),
                    ("Tum Hi Ho", 5),
                    ("Channa Mereya", 5)):
    p.add(Song(title, mins))

print("the playlist")
p.show()

print()
print("now playing :", p.now_playing())
print("next        :", p.play_next())
print("next        :", p.play_next())
print("next        :", p.play_next())
print("next wraps  :", p.play_next())

print()
p.remove("Kun Faya Kun")
print("after removing Kun Faya Kun")
p.show()

print()
p.play_next()
print("now playing :", p.now_playing())
p.remove("Tum Hi Ho")
print("that was what was playing; it moves on to:", p.now_playing())
p.show()
munotes.in165

Practical 12: Singly Linked Lists

the playlist
    Ae Dil Hai Mushkil (4 min) <- playing
    Kun Faya Kun (8 min)
    Tum Hi Ho (5 min)
    Channa Mereya (5 min)
    4 song(s), 22 minutes

now playing : Ae Dil Hai Mushkil (4 min)
next        : Kun Faya Kun (8 min)
next        : Tum Hi Ho (5 min)
next        : Channa Mereya (5 min)
next wraps  : Ae Dil Hai Mushkil (4 min)

after removing Kun Faya Kun
    Ae Dil Hai Mushkil (4 min) <- playing
    Tum Hi Ho (5 min)
    Channa Mereya (5 min)
    3 song(s), 14 minutes

now playing : Tum Hi Ho (5 min)
that was what was playing; it moves on to: Channa Mereya (5 min)
    Ae Dil Hai Mushkil (4 min)
    Channa Mereya (5 min) <- playing
    2 song(s), 9 minutes

Three things that program does which are the reason a linked list suits the job.

The playing pointer is a node, not an index. With a Python list and an index of 2, removing the song at index 0 makes the index wrong: it now points at the song after the one that was playing. With a reference to a node, removing something else does not disturb it at all.

Wrapping round is one line. self.playing.nxt if self.playing.nxt else self.head, or equivalently self.playing.nxt or self.head, gives repeat-all for nothing. A circular linked list, where the last node's nxt is the head rather than None, makes even that unnecessary, and the ready queue of [Practical 8: CPU Scheduling, Round Robin] is exactly that structure.

Removing what is playing has to be handled. The last part of the run shows it: Tum Hi Ho was playing, it was removed, and the pointer moved on to the next song rather than being left pointing at a node that is no longer in the list. A program that forgets this case keeps playing a song that the user has deleted, which is a bug a user notices immediately.

The to-do list, which is the same structure

MU offers a to-do list as the alternative, and it needs nothing new: a Task with a description and a done flag instead of a Song, add at the tail so that tasks stay in the order they were written, remove by description, and a traversal that prints [x] or [ ]. Everything else is the program above. If your college sets the to-do list, that is the change.

munotes.in166

Practical 12: Singly Linked Lists

MU's third bullet: the array against the linked list, measured

The two structures are good at opposite things, so one measurement flatters whichever was chosen. This program measures both.

from time import perf_counter


class Node:
    __slots__ = ("data", "nxt")           # a little less memory per node

    def __init__(self, data, nxt=None):
        self.data = data
        self.nxt = nxt


def build_linked_at_front(n):
    head = None
    for i in range(n):
        head = Node(i, head)              # one new node, no shifting
    return head


def build_array_at_front(n):
    items = []
    for i in range(n):
        items.insert(0, i)                # everything moves up one
    return items


def walk(head, want):
    at, here = 0, head
    while here is not None:
        if here.data == want:
            return at
        here, at = here.nxt, at + 1
    return -1


N = 20000

start = perf_counter()
head = build_linked_at_front(N)
linked_build = perf_counter() - start

start = perf_counter()
items = build_array_at_front(N)
array_build = perf_counter() - start

print(f"building {N} items, adding each at the FRONT")
print(f"  linked list : {linked_build:.4f} seconds")
print(f"  python list : {array_build:.4f} seconds")
print(f"  the list was {array_build / linked_build:.0f} times slower")

middle = N // 2

start = perf_counter()
for _ in range(200):
    got = items[middle]
array_read = perf_counter() - start

start = perf_counter()
for _ in range(200):
    pos = walk(head, items[middle])
linked_read = perf_counter() - start

print()
print(f"reading item {middle}, 200 times over")
print(f"  python list : {array_read:.6f} seconds, by arithmetic")
print(f"  linked list : {linked_read:.6f} seconds, by walking")
print(f"  the walk was {linked_read / array_read:.0f} times slower")
print()
print("and both hold the same thing:", got == items[middle], pos == middle)
building 20000 items, adding each at the FRONT
  linked list : 0.0033 seconds
  python list : 0.1611 seconds
  the list was 49 times slower

reading item 10000, 200 times over
  python list : 0.000026 seconds, by arithmetic
  linked list : 0.118343 seconds, by walking
  the walk was 4508 times slower

and both hold the same thing: True True

Building at the front: the linked list wins by 58 times. Every insert(0, i) on a Python list moves all the items already there up by one, so building n items that way costs about n squared steps. Every Node(i, head) costs two assignments, so the linked list costs about n.

Reading the middle: the Python list wins by 4114 times. items[10000] is one multiplication and one memory read. Walking to node 10000 is ten thousand steps, and the nodes are scattered in memory so nearly every step is a cache miss.

munotes.in167

Practical 12: Singly Linked Lists

Those two figures together are the honest answer, and it is worth stating as a rule:

Use an array when you will read by position. Use a linked list when you will

insert and remove at a position you are already holding.

And the practical footnote: in Python, almost always use the built-in list. It is implemented in C, its append is very fast, and collections.deque gives cheap insertion and removal at both ends. The linked list is on this syllabus because you cannot understand the built-in list, or a file system's block chain, or an operating system's ready queue, without being able to build one.

The cost of every operation

The table the examination asks for. n is the number of nodes.

OperationArraySingly linked listWhy
Read item nO(1)O(n)arithmetic against walking
Insert at the headO(n)O(1)shifting against one node
Insert at the tailO(1)O(1) with a tail, O(n) without
Insert after a node you holdO(n)O(1)this is the linked list's real advantage
Delete at the headO(n)O(1)
Delete a node you holdO(n)O(n) for a singly linked listyou must find its predecessor
Search for a valueO(n)O(n)both walk
Space for n itemsn slots plus sparen items plus n links

Read the "delete a node you hold" row. Even when you are holding the node, a singly linked list cannot delete it in one step, because unlinking needs the node in front. That is the single strongest argument for a doubly linked list, and it is the subject of the next chapter but one.

Procedure

  1. Write the class. Compile is not needed; run it with python3 sll.py.
  2. Swap the two lines of prepend so the head moves first. Run it and note that the list is now

one node long.

  1. Remove the if here is self.tail: self.tail = before line from remove, delete the last item,

and then append something. The new item goes after a node that is no longer in the list.

  1. Add a get(position) method that returns the item at a position and raises IndexError past

the end.

  1. Write the playlist. Remove the song that is playing and check the pointer moves on.
  2. Write the measurement. Run it at N of 5000, 10000 and 20000 and note that the ratio grows with

N, which is what O(n squared) against O(n) means.

  1. Turn the playlist into a circular list, so that the last node's nxt is the head, and

delete the wrap-round line from play_next. Be careful: every traversal must now stop on its own or it never ends.

Result

A singly linked list was built with prepend, append, insert after a value, search, contains, remove, length, iteration and reverse, each maintaining the head, the tail and the count. Removal was shown to require a trailing pointer, and reversal to need three pointers and no new nodes. The list was applied to a playlist in which the currently playing song is held as a node reference, so that additions and removals elsewhere do not disturb it. Against a Python list, building 20000 items at the front was 58 times faster with links, and reading the middle item was 4114 times slower.

munotes.in168

Practical 12: Singly Linked Lists

Where marks are lost

  • Using a Python list. The exercise is to build the structure.
  • No tail, so append walks the list, and then saying append is O(1).
  • Not moving the tail back when the last node is removed.
  • Not handling the head as a special case in remove, so removing the first item does nothing

or crashes.

  • No trailing pointer in remove, which cannot be made to work in a singly linked list.
  • Losing the rest of the list in reverse by overwriting here.nxt before saving it.
  • Forgetting here = here.nxt in a traversal, which never ends.
  • Returning None for a value that is not found instead of -1 or an exception, and then not

being able to distinguish it from a stored None.

  • Saying the linked list is faster, or slower, without saying at what. It is both, and the

marks are in the "at what".

For the journal

Write the aim, MU's own wording, and the picture of three nodes with their links and the trailing None, because that diagram is worth a mark on its own. Then the class in full and its run. Then the playlist with its output, and one sentence on why the playing pointer is a node and not an index. Then the measurement with both figures, your own machine's, and the cost table. The conclusion: a linked list trades position arithmetic for cheap insertion, so it is faster than an array at one end and thousands of times slower in the middle, and which to use is decided by what the program will do most.

Quick revision

  • A node holds an item and a link; the last link is None; the list is a reference to the head.
  • Keep head, tail and count, and maintain all three in every operation.
  • prepend: build the node pointing at the old head, then move the head. Two assignments,

O(1).

  • append is O(1) only because a tail is kept. Without one it is O(n).
  • remove needs a trailing before pointer, because a node cannot reach its predecessor. Three

cases: it was the head, it was in the middle, it was the tail.

munotes.in169

Practical 12: Singly Linked Lists

  • reverse uses three pointers, before, here and after, and saves after before

overwriting here.nxt.

  • __len__, __iter__ with yield, __contains__ and __repr__ make the class behave like a

Python container. Define __contains__ in terms of search so the two cannot disagree.

  • A playlist holds the playing song as a node, not an index, so that changes elsewhere do not

move it. Removing the playing song must move the pointer on.

  • Measured here: building at the front, links 58 times faster; reading the middle, array 4114

times faster.

  • Costs: read O(n) against O(1); insert at the head O(1) against O(n); insert after a node you

hold O(1), which is the linked list's real advantage; delete a node you hold still O(n), which is the argument for a doubly linked list.

Questions you should be able to answer

1. What does a node of a singly linked list hold, and what marks the end? The item and a reference to the next node. The last node's reference is None.

2. Why does append need a tail pointer? Because without one it must walk from the head to the end, which is O(n), so building n items by appending would cost n squared steps. With a tail it is two assignments.

3. Why does remove need a trailing pointer? Because unlinking a node means changing the nxt of the node in front of it, and a singly linked node has no way to reach its predecessor. The trailing pointer is that predecessor.

4. Write the three lines that reverse a singly linked list. Save after = here.nxt, set here.nxt = before, then advance before = here and here = after. When the loop ends, before is the new head. The saving of after must come first.

5. The last node is removed and the tail is not updated. What goes wrong? The tail still points at a node that is no longer in the list, so the next append links the new node on to a detached node and it never appears.

6. Why is a playlist's current song a node reference rather than an index? Because removing or inserting a song elsewhere changes every index after it, and an index would then point at the wrong song. A node reference is unaffected.

7. Which is faster, an array or a linked list? Neither: they are faster at different things. Building 20000 items at the front, the linked list was 58 times faster here; reading the middle item, the Python list was 4114 times faster.

8. Give the one operation a linked list does in constant time that an array cannot. Inserting or deleting at a position you are already holding. An array has to shift everything after it.

munotes.in170

Practical 12: Singly Linked Lists

9. Even holding the node, why can a singly linked list not delete it in one step? Because the deletion changes the previous node's link, and there is no way back from a node to its predecessor. A doubly linked list can, which is why it exists.

Contents This chapter on its own page

munotes.in171

Chapter Twenty

Practical 13: Polynomial Operations Using Linked Lists

Syllabus topic Module 2, "Polynomial Operations Using Linked Lists: Represent polynomials using linked lists. Perform polynomial addition and subtraction by merging lists. Use structured representation to reinforce node manipulation."

Aim

To represent a polynomial as a linked list of terms, to add and subtract two polynomials by merging their lists, and to evaluate and multiply them.

What you need to know before you start

A polynomial is a sum of terms, and each term has two numbers: a coefficient and an exponent.

P(x) = 5x^4 + 3x^2 - 7x + 2

Four terms. The first has coefficient 5 and exponent 4; the last has coefficient 2 and exponent 0, because x to the power 0 is 1.

Why not an array

The obvious representation is an array indexed by exponent: [2, -7, 3, 0, 5] for the polynomial above, where a[i] is the coefficient of x to the i. It is simple and it is right for a dense polynomial.

It is wrong for a sparse one:

R(x) = 7x^100 + 4

Two terms, and an array needs 101 slots, 99 of them zero. A polynomial of degree one million with three terms needs a million and one slots.

A linked list stores only the terms that exist, so R(x) is two nodes. That is the whole reason this exercise uses a list, and the answer to the first question an examiner asks:

Array indexed by exponentLinked list of terms
Spacedegree plus one, however few termsone node per non-zero term
Good fordense polynomials of low degreesparse polynomials, any degree
Coefficient of x to the kone stepwalk until the exponent is k or smaller
Adding twoadd the arrays element by elementmerge the two lists
Degreethe highest index that is not zerothe first node's exponent

The three rules the representation keeps

Everything in this chapter depends on these, and the class enforces all three:

  1. Terms are in descending order of exponent. So the degree is the first node, printing is one

walk, and merging is one pass.

  1. No two terms have the same exponent. Adding a term whose exponent is already present merges

the coefficients instead of making a second node.

  1. No term has a coefficient of zero. A term whose coefficient becomes zero is removed, not

kept, so the zero polynomial is an empty list.

Rule 3 is the one most programs get wrong, and the merge in this chapter shows it firing: the x terms of P and Q are -7x and +7x, and their sum is not a term at all.

The class

class Term:
    """One term: a coefficient and an exponent, and the next term."""

    def __init__(self, coeff, expo, nxt=None):
        self.coeff = coeff
        self.expo = expo
        self.nxt = nxt

    def __repr__(self):
        return f"Term({self.coeff}, {self.expo})"


class Polynomial:
    """A linked list of terms, kept in DESCENDING order of exponent,
    with no two terms of the same exponent and no zero coefficients."""

    def __init__(self, terms=()):
        self.head = None
        for coeff, expo in terms:
            self.add_term(coeff, expo)

    # ---- the one operation everything else is built on -------------------
    def add_term(self, coeff, expo):
        """Insert in the right place, or merge with the term already there."""
        if coeff == 0:
            return                              # a zero term is not stored
        before, here = None, self.head
        while here is not None and here.expo > expo:
            before, here = here, here.nxt

        if here is not None and here.expo == expo:
            here.coeff += coeff                 # merge
            if here.coeff == 0:                 # and it cancelled out
                if before is None:
                    self.head = here.nxt
                else:
                    before.nxt = here.nxt
            return

        node = Term(coeff, expo, here)
        if before is None:
            self.head = node
        else:
            before.nxt = node

    # ---- asking about it -------------------------------------------------
    def degree(self):
        return self.head.expo if self.head else 0

    def terms(self):
        here = self.head
        while here is not None:
            yield here.coeff, here.expo
            here = here.nxt

    def evaluate(self, x):
        return sum(c * x ** e for c, e in self.terms())

    def __repr__(self):
        if self.head is None:
            return "0"
        out = []
        for c, e in self.terms():
            sign = "-" if c < 0 else "+"
            mag = abs(c)
            if e == 0:
                piece = f"{mag}"
            elif e == 1:
                piece = f"{mag}x" if mag != 1 else "x"
            else:
                piece = f"{mag}x^{e}" if mag != 1 else f"x^{e}"
            out.append((sign, piece))
        first_sign, first = out[0]
        text = ("-" if first_sign == "-" else "") + first
        for sign, piece in out[1:]:
            text += f" {sign} {piece}"
        return text

    # ---- the operations MU asks for --------------------------------------
    def __add__(self, other):
        """Merge two lists. Neither operand is changed."""
        result = Polynomial()
        for c, e in self.terms():
            result.add_term(c, e)
        for c, e in other.terms():
            result.add_term(c, e)
        return result

    def __sub__(self, other):
        result = Polynomial()
        for c, e in self.terms():
            result.add_term(c, e)
        for c, e in other.terms():
            result.add_term(-c, e)
        return result

    def __mul__(self, other):
        result = Polynomial()
        for c1, e1 in self.terms():
            for c2, e2 in other.terms():
                result.add_term(c1 * c2, e1 + e2)
        return result


p = Polynomial([(5, 4), (3, 2), (-7, 1), (2, 0)])
q = Polynomial([(4, 4), (2, 3), (7, 1), (-9, 0)])

print("P(x) =", p, " degree", p.degree())
print("Q(x) =", q, " degree", q.degree())
print()
print("P + Q =", p + q)
print("P - Q =", p - q)
print("Q - P =", q - p)
print()
print("the terms of P as a list:", list(p.terms()))
print()
print("P(2) =", p.evaluate(2))
print("Q(2) =", q.evaluate(2))
print("(P + Q)(2) =", (p + q).evaluate(2), "and P(2) + Q(2) =",
      p.evaluate(2) + q.evaluate(2))
print()
print("P is unchanged by all of that:", p)
munotes.in172

Practical 13: Polynomial Operations Using Linked Lists

P(x) = 5x^4 + 3x^2 - 7x + 2  degree 4
Q(x) = 4x^4 + 2x^3 + 7x - 9  degree 4

P + Q = 9x^4 + 2x^3 + 3x^2 - 7
P - Q = x^4 - 2x^3 + 3x^2 - 14x + 11
Q - P = -x^4 + 2x^3 - 3x^2 + 14x - 11

the terms of P as a list: [(5, 4), (3, 2), (-7, 1), (2, 0)]

P(2) = 80
Q(2) = 85
(P + Q)(2) = 165 and P(2) + Q(2) = 165

P is unchanged by all of that: 5x^4 + 3x^2 - 7x + 2
munotes.in173

Practical 13: Polynomial Operations Using Linked Lists

add_term is the only operation that manipulates nodes

Everything else is built on it, which is why it carries all the care. It has the same three cases as remove in [Practical 12: Singly Linked Lists], plus a fourth of its own.

before, here = None, self.head
while here is not None and here.expo > expo:
    before, here = here, here.nxt

That walk stops at the first term whose exponent is not greater than the new one, which is where the new term belongs. Then:

CaseWhat happens
here has the same exponentmerge: add the coefficients
the merge made it zerounlink that node, the term has cancelled
before is Nonethe new term is the largest: it becomes the head
otherwiselink it in between before and here

The trailing before pointer is needed for the same reason as in the last chapter, and here it is needed twice: once to link a new node in and once to unlink a cancelled one.

The coefficient of zero is refused at the top, before any walking, so add_term(0, 5) does nothing at all. That is rule 3 applied at the door.

Reading the output

P + Q lost its x term. P has -7x and Q has +7x, and the sum has no term of exponent 1: 9x^4 + 2x^3 + 3x^2 - 7. A program that kept a zero term would print 9x^4 + 2x^3 + 3x^2 + 0x - 7, which is not wrong arithmetic but is wrong output, and an examiner marks it down.

Q - P is the negative of P - Q, term for term, which is the cheapest check there is on a subtraction and is worth printing.

Subtraction is addition with the signs flipped. __sub__ is __add__ with -c in the second loop, and that is the whole of it. A student who writes a separate subtraction routine has written twice as much code and twice as many bugs.

Neither operand is changed. p + q builds a new polynomial and copies both, so the last line can print P and find it intact. That matters: an __add__ that modified self would make p + q + r give the wrong answer and would astonish anybody who wrote total = total + p in a loop.

munotes.in174

Practical 13: Polynomial Operations Using Linked Lists

The arithmetic is checked two ways. (P + Q)(2) and P(2) + Q(2) are both 165. They are computed by different routes, so a bug in the merge would make them differ.

Addition as a true merge, which is what MU asks for

The __add__ above copies both polynomials into a new one with add_term, which is correct and is not a merge: each insertion walks the result, so in the worst case it costs m times n steps.

A merge walks each list once, comparing the two front terms and taking the larger exponent. It is the same idea as merging two sorted lists, and it costs m plus n.

class Term:
    """One term: a coefficient and an exponent, and the next term."""

    def __init__(self, coeff, expo, nxt=None):
        self.coeff = coeff
        self.expo = expo
        self.nxt = nxt

    def __repr__(self):
        return f"Term({self.coeff}, {self.expo})"


class Polynomial:
    """A linked list of terms, kept in DESCENDING order of exponent,
    with no two terms of the same exponent and no zero coefficients."""

    def __init__(self, terms=()):
        self.head = None
        for coeff, expo in terms:
            self.add_term(coeff, expo)

    # ---- the one operation everything else is built on -------------------
    def add_term(self, coeff, expo):
        """Insert in the right place, or merge with the term already there."""
        if coeff == 0:
            return                              # a zero term is not stored
        before, here = None, self.head
        while here is not None and here.expo > expo:
            before, here = here, here.nxt

        if here is not None and here.expo == expo:
            here.coeff += coeff                 # merge
            if here.coeff == 0:                 # and it cancelled out
                if before is None:
                    self.head = here.nxt
                else:
                    before.nxt = here.nxt
            return

        node = Term(coeff, expo, here)
        if before is None:
            self.head = node
        else:
            before.nxt = node

    # ---- asking about it -------------------------------------------------
    def degree(self):
        return self.head.expo if self.head else 0

    def terms(self):
        here = self.head
        while here is not None:
            yield here.coeff, here.expo
            here = here.nxt

    def evaluate(self, x):
        return sum(c * x ** e for c, e in self.terms())

    def __repr__(self):
        if self.head is None:
            return "0"
        out = []
        for c, e in self.terms():
            sign = "-" if c < 0 else "+"
            mag = abs(c)
            if e == 0:
                piece = f"{mag}"
            elif e == 1:
                piece = f"{mag}x" if mag != 1 else "x"
            else:
                piece = f"{mag}x^{e}" if mag != 1 else f"x^{e}"
            out.append((sign, piece))
        first_sign, first = out[0]
        text = ("-" if first_sign == "-" else "") + first
        for sign, piece in out[1:]:
            text += f" {sign} {piece}"
        return text

    # ---- the operations MU asks for --------------------------------------
    def __add__(self, other):
        """Merge two lists. Neither operand is changed."""
        result = Polynomial()
        for c, e in self.terms():
            result.add_term(c, e)
        for c, e in other.terms():
            result.add_term(c, e)
        return result

    def __sub__(self, other):
        result = Polynomial()
        for c, e in self.terms():
            result.add_term(c, e)
        for c, e in other.terms():
            result.add_term(-c, e)
        return result

    def __mul__(self, other):
        result = Polynomial()
        for c1, e1 in self.terms():
            for c2, e2 in other.terms():
                result.add_term(c1 * c2, e1 + e2)
        return result


def merge_two(a, b):
    """Addition as a TRUE merge: one walk down each list, no re-insertion.
    This is what an examiner means by "merging lists", and it is O(m + n)
    where add_term in a loop is O(m * n) in the worst case."""
    result = Polynomial()
    tail = None
    x, y = a.head, b.head

    while x is not None or y is not None:
        if y is None or (x is not None and x.expo > y.expo):
            coeff, expo = x.coeff, x.expo
            x = x.nxt
        elif x is None or y.expo > x.expo:
            coeff, expo = y.coeff, y.expo
            y = y.nxt
        else:                                   # the same exponent: add them
            coeff, expo = x.coeff + y.coeff, x.expo
            x, y = x.nxt, y.nxt

        print(f"    took exponent {expo}, coefficient {coeff}"
              f"{'  (dropped, it is zero)' if coeff == 0 else ''}")
        if coeff == 0:
            continue                            # a cancelled term is not stored

        node = Term(coeff, expo)
        if tail is None:
            result.head = node
        else:
            tail.nxt = node
        tail = node

    return result


p = Polynomial([(5, 4), (3, 2), (-7, 1), (2, 0)])
q = Polynomial([(4, 4), (2, 3), (7, 1), (-9, 0)])

print("P(x) =", p)
print("Q(x) =", q)
print()
print("merging them, one step at a time:")
merged = merge_two(p, q)
print("  result:", merged)
print()
print("the same answer as add_term in a loop:", repr(merged) == repr(p + q))

print()
print("multiplication, which is every term against every term")
a = Polynomial([(1, 1), (1, 0)])
b = Polynomial([(1, 1), (-1, 0)])
print("  (", a, ") * (", b, ") =", a * b)
print("  (", p, ") * (", Polynomial([(2, 1)]), ") =", p * Polynomial([(2, 1)]))
print()
print("and the three edge cases")
zero = Polynomial()
print("  the zero polynomial            :", zero, " degree", zero.degree())
print("  P + the zero polynomial        :", p + zero)
print("  P - P, which must be zero      :", p - p)
munotes.in175

Practical 13: Polynomial Operations Using Linked Lists

P(x) = 5x^4 + 3x^2 - 7x + 2
Q(x) = 4x^4 + 2x^3 + 7x - 9

merging them, one step at a time:
    took exponent 4, coefficient 9
    took exponent 3, coefficient 2
    took exponent 2, coefficient 3
    took exponent 1, coefficient 0  (dropped, it is zero)
    took exponent 0, coefficient -7
  result: 9x^4 + 2x^3 + 3x^2 - 7

the same answer as add_term in a loop: True

multiplication, which is every term against every term
  ( x + 1 ) * ( x - 1 ) = x^2 - 1
  ( 5x^4 + 3x^2 - 7x + 2 ) * ( 2x ) = 10x^5 + 6x^3 - 14x^2 + 4x

and the three edge cases
  the zero polynomial            : 0  degree 0
  P + the zero polynomial        : 5x^4 + 3x^2 - 7x + 2
  P - P, which must be zero      : 0
munotes.in176

Practical 13: Polynomial Operations Using Linked Lists

The three-way comparison is the whole algorithm

At each step there are exactly three possibilities, and the program has one branch for each:

The two front exponentsTake
the first list's is largerthe first list's term, and step that list on
the second list's is largerthe second list's term, and step that list on
they are equalthe sum of the two coefficients, and step both lists on

And then the fourth rule: if that sum is zero, the term is not stored at all. The trace shows it: took exponent 1, coefficient 0 (dropped, it is zero).

The two lists run out at different times, and both cases must be handled. The conditions y is None or ... and x is None or ... are what do it: when one list is exhausted the rest of the other is simply taken. A program that stops when either list ends loses the tail of the longer one, and that is the commonest bug in this exercise. Here P and Q both have four terms, so try it with polynomials of different lengths.

The result is built at the tail, with a tail pointer, not with add_term. That is what makes it O(m + n): the terms come out in descending order already, so each one is appended in one step and nothing is searched. Using add_term here would throw the merge's advantage away.

And the program proves the two routes agree, with repr(merged) == repr(p + q). Two implementations of the same operation that are checked against each other is the cheapest test in this module, and it would catch any of the mistakes above.

Multiplication

Every term of one against every term of the other, adding the exponents and multiplying the coefficients. add_term does the merging, so the whole operation is a double loop:

for c1, e1 in self.terms():
    for c2, e2 in other.terms():
        result.add_term(c1 * c2, e1 + e2)

The run shows (x + 1) * (x - 1) = x^2 - 1, which is worth having in the journal because the middle terms cancel and it therefore exercises rule 3 as well. Multiplication is O(m times n) unavoidably, because there are that many products, and it cannot be done by merging.

munotes.in177

Practical 13: Polynomial Operations Using Linked Lists

The edge cases

Three, and all three are printed:

  • The zero polynomial is an empty list, prints as 0, and its degree is reported as 0. (A

mathematician would say the zero polynomial has no degree, or degree minus infinity; a program has to return something, and 0 with a note is the usual choice.)

  • P plus zero is P.
  • P minus P is the zero polynomial, not a list of four zero terms. That is rule 3 doing its

job four times over.

The cost of each operation

OperationCostWhy
add_termO(t), t the terms already thereit walks to the right place
Building a polynomial of t termsO(t squared) with add_termeach insertion walks
DegreeO(1)the first node
Evaluate at xO(t)one pass
Add or subtract, by mergeO(m + n)one pass down each
Add or subtract, with add_termO(m times n) worst caseeach insertion walks the result
MultiplyO(m times n)that many products exist
PrintO(t)one pass

Procedure

  1. Write the class and run it. Check P + Q against your own arithmetic on paper.
  2. Add (7, 2) to P with add_term and confirm the 3x squared becomes 10x squared rather than a

second term appearing.

  1. Add (-3, 2) to P and confirm the x squared term disappears entirely.
  2. Write the merge and follow the trace. Then try it with P of four terms and Q of two, so that

one list runs out first.

  1. Break the merge deliberately: change the loop to while x is not None and y is not None and

note which terms are lost.

  1. Evaluate P at 0, 1 and 2 and check each by hand. P(0) should be the constant term.
  2. Write a derivative method: every term becomes coefficient times exponent, with the exponent

one less, and the constant term disappears.

Result

A polynomial was represented as a linked list of terms in descending order of exponent, with no duplicate exponents and no zero coefficients. Addition and subtraction were implemented twice, once by term-by-term insertion and once as a true single-pass merge, and the program proved the two give the same answer. The merge was shown dropping a term whose coefficients cancelled, which is the case that distinguishes a complete answer. Evaluation was checked by computing (P + Q)(2) and P(2) + Q(2) separately and finding both to be 165, and multiplication was demonstrated on (x + 1)(x - 1), whose middle terms cancel.

munotes.in178

Practical 13: Polynomial Operations Using Linked Lists

Where marks are lost

  • Keeping a term whose coefficient is zero, so the answer prints + 0x.
  • Making a second node for an exponent already present, so the polynomial has two x squared

terms and every later operation is wrong.

  • Not keeping the list in descending order, after which the degree, the printing and the merge

all stop working.

  • Stopping the merge when either list ends, losing the tail of the longer one.
  • Writing a separate subtraction routine instead of adding the negated terms.
  • Modifying an operand in __add__, so p + q damages p.
  • Using add_term inside the merge, which throws away the reason for merging.
  • Printing x^1 and x^0 instead of x and the bare constant.
  • Forgetting the coefficient of 1, so the answer prints 1x^4.

For the journal

Write the aim, MU's own wording, and the two representations, array and linked list, with the sparse example that shows why the list is used. Draw P and Q as chains of nodes with their coefficients and exponents. Then the class, its run, and the merge with its step-by-step trace, because the trace is the evidence that it is a merge and not repeated insertion. Circle the line where the x term is dropped. Then the multiplication of (x + 1)(x - 1) and the three edge cases. The conclusion: a linked list stores only the terms that exist, so a sparse polynomial costs nothing extra, and addition is a single pass down both lists provided they are kept in descending order.

Quick revision

  • A term is a coefficient and an exponent. A polynomial is a linked list of terms.
  • Three rules: descending order of exponent, no duplicate exponents, no zero coefficients.
  • A linked list beats an array indexed by exponent for a sparse polynomial: 7x^100 + 4 is two

nodes against 101 slots.

  • add_term walks to the first exponent not greater than the new one, then merges, unlinks a

cancelled term, or links a new node in.

  • Subtraction is addition with the coefficients negated. Do not write it twice.
  • A true merge walks each list once, comparing front exponents: take the larger, or add them when

equal and step both. O(m + n).

  • The merge must handle one list running out before the other, or the tail of the longer one is

lost.

  • A term whose coefficients cancel is not stored. P - P is the empty list.
  • Build the merged result at the tail, not with add_term, or the merge costs O(m times n)

again.

  • Multiplication is every term against every term, O(m times n), and cannot be merged.
  • The degree is the first node's exponent, in O(1). Evaluation is one pass.
munotes.in179

Practical 13: Polynomial Operations Using Linked Lists

Questions you should be able to answer

1. Why represent a polynomial as a linked list rather than an array? Because an array indexed by exponent needs a slot for every power up to the degree, most of them zero for a sparse polynomial. A linked list stores only the terms that exist, so 7x^100 + 4 is two nodes rather than 101 slots.

2. What three rules does the representation keep? Terms in descending order of exponent, no two terms with the same exponent, and no term with a zero coefficient.

3. What happens when a term is added whose exponent is already present? The coefficients are added into the existing node. If the sum is zero the node is unlinked, because a zero term is not stored.

4. How is subtraction implemented? As addition with the second polynomial's coefficients negated. No separate routine is needed.

5. What are the three cases at each step of a merge? The first list's front exponent is larger, so take its term; the second's is larger, so take that one; or they are equal, so add the coefficients and step both lists on. In the third case, if the sum is zero nothing is stored.

6. Why must a merge not stop when either list ends? Because the other list may still have terms, and they all belong in the answer. The loop continues while either list has terms left.

7. Why is the merged result built with a tail pointer rather than with add_term? Because the terms already come out in descending order, so each can be appended in one step. Using add_term would search the result for every term and make the merge O(m times n) instead of O(m + n).

8. P has four terms and Q has four terms. Why does P + Q have four and not eight? Because the exponents overlap: the x^4 terms merged, and the x terms, -7x and +7x, cancelled and were dropped. The result is 9x^4 + 2x^3 + 3x^2 - 7.

9. Can multiplication be done by merging? No. There are m times n products to form, so that is the cost, and add_term is what merges each product into the growing answer.

Contents This chapter on its own page

munotes.in180

Chapter Twenty-One

Practical 14: Doubly Linked Lists

Syllabus topic Module 2, "Working with Doubly Linked Lists: Create a doubly linked list with forward and backward traversal. Implement insertion/deletion at head, tail, and specific positions. Use in scenarios like browser history or undo-redo features."

Aim

To create a doubly linked list with forward and backward traversal, to insert and delete at the head, the tail and a given position, and to use it for browser history and for undo and redo.

What you need to know before you start

[Practical 12: Singly Linked Lists] ended on a defect. Even holding the node you want to delete, a singly linked list cannot delete it, because unlinking changes the previous node's link and there is no way back.

A doubly linked list gives every node two links, one each way:

None <- [ * | English | * ] <-> [ * | Physics | * ] <-> [ * | Maths | * ] -> None

Each node has prv and nxt. The head's prv is None and the tail's nxt is None, and now three things become possible that were not:

Singly linkedDoubly linked
Traverse backwardsnoyes
Delete a node you holdO(n), find its predecessorO(1)
Insert before a node you holdO(n)O(1)
Links per node12
Memorylowerone extra reference a node
Codesimplerevery operation must maintain two links

The price is in the last two rows and it is real: every insertion and every deletion has to fix two links, in both directions, and a program that fixes one and forgets the other still works going forwards and is wrong going backwards. That is the single characteristic bug of this exercise, and the way to catch it is to print the list both ways after every operation.

The class

class Node:
    """Two links this time: the one in front and the one behind."""

    def __init__(self, data, prv=None, nxt=None):
        self.data = data
        self.prv = prv
        self.nxt = nxt

    def __repr__(self):
        return f"Node({self.data!r})"


class DoublyLinkedList:
    """Head and tail, and every node knows both its neighbours."""

    def __init__(self, items=()):
        self.head = None
        self.tail = None
        self.count = 0
        for x in items:
            self.append(x)

    def __len__(self):
        return self.count

    def __iter__(self):
        here = self.head
        while here is not None:
            yield here.data
            here = here.nxt

    def backwards(self):
        here = self.tail
        while here is not None:
            yield here.data
            here = here.prv

    def __repr__(self):
        return "None <- " + " <-> ".join(repr(x) for x in self) + " -> None"

    # ---- inserting -------------------------------------------------------
    def prepend(self, data):
        node = Node(data, None, self.head)
        if self.head is None:
            self.head = self.tail = node
        else:
            self.head.prv = node
            self.head = node
        self.count += 1
        return node

    def append(self, data):
        node = Node(data, self.tail, None)
        if self.tail is None:
            self.head = self.tail = node
        else:
            self.tail.nxt = node
            self.tail = node
        self.count += 1
        return node

    def insert_at(self, position, data):
        """0 puts it at the head, len() puts it at the tail."""
        if position < 0 or position > self.count:
            raise IndexError(f"position {position} is outside 0 to {self.count}")
        if position == 0:
            return self.prepend(data)
        if position == self.count:
            return self.append(data)
        here = self.node_at(position)          # the node it goes BEFORE
        node = Node(data, here.prv, here)
        here.prv.nxt = node
        here.prv = node
        self.count += 1
        return node

    # ---- finding ---------------------------------------------------------
    def node_at(self, position):
        if position < 0 or position >= self.count:
            raise IndexError(f"position {position} is outside 0 to {self.count - 1}")
        # from whichever end is nearer, which a singly linked list cannot do
        if position <= self.count // 2:
            here, steps = self.head, position
            for _ in range(steps):
                here = here.nxt
        else:
            here, steps = self.tail, self.count - 1 - position
            for _ in range(steps):
                here = here.prv
        return here

    def find(self, target):
        here = self.head
        while here is not None:
            if here.data == target:
                return here
            here = here.nxt
        return None

    # ---- removing --------------------------------------------------------
    def unlink(self, node):
        """Remove a node you are HOLDING. One step, no searching.
        This is the operation a singly linked list cannot do."""
        if node.prv is None:
            self.head = node.nxt
        else:
            node.prv.nxt = node.nxt
        if node.nxt is None:
            self.tail = node.prv
        else:
            node.nxt.prv = node.prv
        node.prv = node.nxt = None
        self.count -= 1
        return node.data

    def remove(self, target):
        node = self.find(target)
        if node is None:
            raise ValueError(f"{target!r} is not in the list")
        return self.unlink(node)


d = DoublyLinkedList(["Chemistry", "Maths", "Biology"])
print("built by appending  :", d)
print("length              :", len(d))

d.prepend("English")
print("after prepend       :", d)

d.insert_at(2, "Physics")
print("insert_at(2)        :", d)

print()
print("forwards            :", list(d))
print("backwards           :", list(d.backwards()))
print("head and tail       :", d.head, d.tail)
print("node_at(0)          :", d.node_at(0), " node_at(4):", d.node_at(4))

print()
print("every node knows both its neighbours:")
here = d.head
while here is not None:
    before = here.prv.data if here.prv else None
    after = here.nxt.data if here.nxt else None
    print(f"    {here.data:<10} prv {str(before):<10} nxt {after}")
    here = here.nxt

print()
maths = d.find("Maths")
print("unlink the node we are holding:", d.unlink(maths))
print("after                :", d)
print("remove('English')    :", d.remove("English"))
print("after                :", d)
print("backwards still right:", list(d.backwards()))

print()
d.remove("Chemistry")
d.remove("Physics")
d.remove("Biology")
print("after removing everything:", len(d), "nodes, head", d.head, "tail", d.tail)

print()
for bad in (-1, 99):
    try:
        d.insert_at(bad, "x")
    except IndexError as e:
        print(f"insert_at({bad}): IndexError: {e}")
try:
    d.remove("nothing")
except ValueError as e:
    print("remove('nothing'): ValueError:", e)
munotes.in181

Practical 14: Doubly Linked Lists

built by appending  : None <- 'Chemistry' <-> 'Maths' <-> 'Biology' -> None
length              : 3
after prepend       : None <- 'English' <-> 'Chemistry' <-> 'Maths' <-> 'Biology' -> None
insert_at(2)        : None <- 'English' <-> 'Chemistry' <-> 'Physics' <-> 'Maths' <-> 'Biology' -> None

forwards            : ['English', 'Chemistry', 'Physics', 'Maths', 'Biology']
backwards           : ['Biology', 'Maths', 'Physics', 'Chemistry', 'English']
head and tail       : Node('English') Node('Biology')
node_at(0)          : Node('English')  node_at(4): Node('Biology')

every node knows both its neighbours:
    English    prv None       nxt Chemistry
    Chemistry  prv English    nxt Physics
    Physics    prv Chemistry  nxt Maths
    Maths      prv Physics    nxt Biology
    Biology    prv Maths      nxt None

unlink the node we are holding: Maths
after                : None <- 'English' <-> 'Chemistry' <-> 'Physics' <-> 'Biology' -> None
remove('English')    : English
after                : None <- 'Chemistry' <-> 'Physics' <-> 'Biology' -> None
backwards still right: ['Biology', 'Physics', 'Chemistry']

after removing everything: 0 nodes, head None tail None

insert_at(-1): IndexError: position -1 is outside 0 to 0
insert_at(99): IndexError: position 99 is outside 0 to 0
remove('nothing'): ValueError: 'nothing' is not in the list
munotes.in182

Practical 14: Doubly Linked Lists

The four links of an insertion

Inserting a node between before and after means four assignments, and a program that makes three of them is broken in a way that only a backward traversal reveals:

node.prv = before          # 1. the new node looks back
node.nxt = after           # 2. and forward
before.nxt = node          # 3. the one behind looks at it
after.prv = node           # 4. and the one in front looks back at it

In the program, 1 and 2 are done by the Node constructor and 3 and 4 are the two lines after it. The two special cases are when before is None, so the new node is the head, and when after is None, so it is the tail; in each of those the missing assignment is replaced by moving head or tail.

The printed table of neighbours is the check, and it is worth putting in the journal. Every node's prv must be the node before it and its nxt the node after it. Read the run above: the head's prv is None, the tail's nxt is None, and every pair agrees.

unlink is the operation that justifies the whole structure

def unlink(self, node):
    if node.prv is None: self.head = node.nxt
    else:                node.prv.nxt = node.nxt
    if node.nxt is None: self.tail = node.prv
    else:                node.nxt.prv = node.prv
    node.prv = node.nxt = None

Four branches and no searching. Compare remove in [Practical 12: Singly Linked Lists], which had to walk the list with a trailing pointer to find the predecessor. Here the predecessor is node.prv, so given the node, deletion is a constant number of steps however long the list is.

That single property is why a doubly linked list is the structure under an LRU cache, under a process scheduler's queues, and under Python's own collections.deque. The [Practical 9: Memory Management, FIFO and LRU Page Replacement] chapter said that a real LRU implementation keeps the pages in a list and moves a page to the front when it is used. Moving a node to the front is an unlink and a prepend, and both are O(1) only in a doubly linked list.

munotes.in183

Practical 14: Doubly Linked Lists

node.prv = node.nxt = None at the end is not tidiness. An unlinked node that still points into the list lets a caller who kept a reference to it walk back into a structure it is no longer part of, and the bug that results is very hard to find.

node_at searches from the nearer end

if position <= self.count // 2:
    here = self.head;  step forwards `position` times
else:
    here = self.tail;  step backwards `count - 1 - position` times

A singly linked list must always start at the head, so reaching the last node of a list of 1000 takes 1000 steps. A doubly linked list takes one. It is still O(n) in the worst case, the middle, but the constant is halved, and it costs three lines.

MU's two applications, which are one structure

MU names browser history and undo-redo. They look different and they are the same idea: a doubly linked list with a cursor, where

  • going back moves the cursor one step towards the head,
  • going forward moves it one step towards the tail,
  • and doing something new attaches a node after the cursor and

discards whatever was in front.

That last rule is the one students leave out, and it is what the second program demonstrates twice.

class Node:
    def __init__(self, data, prv=None, nxt=None):
        self.data = data
        self.prv = prv
        self.nxt = nxt


class BrowserHistory:
    """A doubly linked list with a pointer to the page you are on.
    Back and forward are one step each, which is the whole point."""

    def __init__(self, first_page):
        self.here = Node(first_page)

    def visit(self, page):
        """Going somewhere new throws away everything in FRONT of you."""
        node = Node(page, self.here, None)
        self.here.nxt = node                    # the old forward chain is dropped
        self.here = node

    def back(self):
        if self.here.prv is None:
            raise IndexError("there is nothing behind this page")
        self.here = self.here.prv
        return self.here.data

    def forward(self):
        if self.here.nxt is None:
            raise IndexError("there is nothing in front of this page")
        self.here = self.here.nxt
        return self.here.data

    def show(self):
        start = self.here
        while start.prv is not None:
            start = start.prv
        parts = []
        node = start
        while node is not None:
            parts.append(f"[{node.data}]" if node is self.here else node.data)
            node = node.nxt
        print("    " + "  <->  ".join(parts))


b = BrowserHistory("munotes.in")
b.visit("munotes.in/notes")
b.visit("munotes.in/notes/BSc-Computer-Science")
b.visit("munotes.in/syllabus")
print("after four pages, the current one in brackets")
b.show()

print()
print("back    ->", b.back())
print("back    ->", b.back())
b.show()
print("forward ->", b.forward())
b.show()

print()
print("now visit somewhere new from the middle")
b.visit("munotes.in/aibe")
b.show()
try:
    b.forward()
except IndexError as e:
    print("forward now: IndexError:", e)

print()
while True:
    try:
        b.back()
    except IndexError as e:
        print("going back to the start, then once more: IndexError:", e)
        break
b.show()


class Editor:
    """Undo and redo over the same idea: one list, one cursor."""

    def __init__(self):
        self.text = ""
        self.done = Node(("start", "", ""))     # a sentinel we never undo past
        self.here = self.done

    def type(self, s):
        before = self.text
        self.text = self.text + s
        self.record(("type", s, before))

    def delete(self, n):
        if n > len(self.text):
            raise ValueError(f"there are only {len(self.text)} characters")
        before = self.text
        self.text = self.text[:-n]
        self.record(("delete", before[-n:], before))

    def record(self, action):
        node = Node(action, self.here, None)
        self.here.nxt = node                    # anything redoable is dropped
        self.here = node

    def undo(self):
        if self.here.prv is None:
            raise IndexError("there is nothing to undo")
        what, part, before = self.here.data
        self.text = before
        self.here = self.here.prv
        return what, part

    def redo(self):
        if self.here.nxt is None:
            raise IndexError("there is nothing to redo")
        self.here = self.here.nxt
        what, part, before = self.here.data
        if what == "type":
            self.text = before + part
        else:
            self.text = before[:-len(part)]
        return what, part


e = Editor()
e.type("Computer ")
e.type("Science ")
e.type("Practical 3")
print()
print("the text:", repr(e.text))

print("undo     ->", e.undo(), "text now", repr(e.text))
print("undo     ->", e.undo(), "text now", repr(e.text))
print("redo     ->", e.redo(), "text now", repr(e.text))

print()
e.type("Module 2")
print("typing after an undo drops the redo chain; text:", repr(e.text))
try:
    e.redo()
except IndexError as ex:
    print("redo now: IndexError:", ex)

print()
e.delete(8)
print("after delete(8):", repr(e.text))
print("undo the delete ->", e.undo(), "text now", repr(e.text))
munotes.in184

Practical 14: Doubly Linked Lists

after four pages, the current one in brackets
    munotes.in  <->  munotes.in/notes  <->  munotes.in/notes/BSc-Computer-Science  <->  [munotes.in/syllabus]

back    -> munotes.in/notes/BSc-Computer-Science
back    -> munotes.in/notes
    munotes.in  <->  [munotes.in/notes]  <->  munotes.in/notes/BSc-Computer-Science  <->  munotes.in/syllabus
forward -> munotes.in/notes/BSc-Computer-Science
    munotes.in  <->  munotes.in/notes  <->  [munotes.in/notes/BSc-Computer-Science]  <->  munotes.in/syllabus

now visit somewhere new from the middle
    munotes.in  <->  munotes.in/notes  <->  munotes.in/notes/BSc-Computer-Science  <->  [munotes.in/aibe]
forward now: IndexError: there is nothing in front of this page

going back to the start, then once more: IndexError: there is nothing behind this page
    [munotes.in]  <->  munotes.in/notes  <->  munotes.in/notes/BSc-Computer-Science  <->  munotes.in/aibe

the text: 'Computer Science Practical 3'
undo     -> ('type', 'Practical 3') text now 'Computer Science '
undo     -> ('type', 'Science ') text now 'Computer '
redo     -> ('type', 'Science ') text now 'Computer Science '

typing after an undo drops the redo chain; text: 'Computer Science Module 2'
redo now: IndexError: there is nothing to redo

after delete(8): 'Computer Science '
undo the delete -> ('delete', 'Module 2') text now 'Computer Science Module 2'

Browser history

Follow the run. Four pages are visited, so the cursor is at the fourth. Two backs move it to the second, and forward moves it to the third; the pages in front are still there, which is why the forward button is not greyed out.

Then visit("munotes.in/aibe") from the middle, and the page that was in front is gone. The list now ends at the new page, and forward raises. That is exactly what a real browser does: go back twice, follow a different link, and the forward button goes grey. A program that appends at the tail instead of after the cursor keeps a forward history the user can never legitimately reach.

munotes.in185

Practical 14: Doubly Linked Lists

The last part goes back to the first page and then once more, and the error says there is nothing behind it. A real browser greys the back button out; a program raises, and the caller decides what to show.

Undo and redo

The same three rules with different words:

BrowserEditor
visit a pagedo something
backundo
forwardredo
visiting from the middle drops the forward pagesdoing something after an undo drops the redo chain

And the run shows the fourth row firing: after two undos and one redo, typing Module 2 makes redo raise, because the action that was redoable has been replaced.

Each node records what was done and what the text was before, which is what makes undo possible: ("type", "Practical 3", "Computer Science ") says the action, the part it added, and the text to go back to. Storing the state before is the simplest correct scheme and it is what the program does. The alternative is to store only the action and reverse it, which uses less memory and is harder to get right for an action like "replace all".

The sentinel node. self.done starts as a node holding ("start", "", "") that nothing ever undoes past, so undo needs no special case for the beginning: it raises when self.here.prv is None, and that is only true at the sentinel. A sentinel or dummy node is the standard trick for removing the special cases from a linked structure, and it is worth knowing by name.

The cost of each operation

OperationSingly linkedDoubly linked
Insert at the headO(1)O(1)
Insert at the tailO(1) with a tailO(1)
Insert at position kO(k)O(min(k, n - k))
Delete at the headO(1)O(1)
Delete at the tailO(n)O(1)
Delete a node you holdO(n)O(1)
Traverse forwardsO(n)O(n)
Traverse backwardsnot possibleO(n)
Space per node1 link2 links

The three rows in bold are the whole argument for the structure.

Procedure

  1. Write the class and run it. Print the list forwards and backwards after every operation.
  2. Delete the line after.prv = node from insert_at (it is here.prv = node in the listing).

Run it: the forward traversal is right and the backward one is wrong. That is the bug to recognise.

  1. Add a middle() method that returns the item in the middle, using node_at.
  2. Write the browser history. Go back twice, visit a new page, and confirm that forward raises.
  3. Add an is_back_possible() and is_forward_possible() to it, which is what a real browser
munotes.in186

Practical 14: Doubly Linked Lists

uses to grey the buttons out.

  1. Write the editor. Type three things, undo twice, redo once, type something, and confirm the

redo chain is gone.

  1. Make the list circular and doubly linked: the head's prv is the tail and the tail's nxt

is the head. Note that unlink then needs no branches at all, and that every traversal must count rather than wait for None.

Result

A doubly linked list was built with forward and backward traversal and with insertion and deletion at the head, the tail and a given position, each maintaining both links in both directions. A node held by the caller was unlinked in a constant number of steps, which a singly linked list cannot do. node_at was made to search from whichever end is nearer. The same structure with a cursor was then used for browser history and for undo and redo, and in both the rule that doing something new discards what was in front of the cursor was demonstrated, with forward and redo raising afterwards.

Where marks are lost

  • Fixing one link and not the other. The list then reads correctly forwards and wrongly

backwards, and only a backward traversal finds it.

  • Not moving head or tail when the node inserted or removed was at an end.
  • Not clearing prv and nxt on an unlinked node, leaving a dangling reference into the

list.

  • Appending at the tail in the browser history instead of after the cursor, so a forward

history survives that should have been discarded.

  • No error when there is nothing to go back to, so the cursor walks off the end.
  • Storing only the action in the editor and not the state before, and then getting the

reversal wrong.

  • Claiming a doubly linked list has no disadvantage. It uses one extra reference a node and

every operation has twice as many links to fix.

  • Saying deletion is O(1). It is O(1) given the node; finding the node is still O(n).

For the journal

Write the aim, MU's own wording, and the picture of three nodes with both sets of arrows and the two Nones at the ends. Then the class and its run, including the table of every node's prv and nxt, because that table is the proof that both links are maintained. Then the browser history with its run, and circle the point where visiting a new page from the middle discards the pages in front. Then the editor, and the table showing that it is the same three rules with different words. The conclusion: a second link per node buys backward traversal and constant-time deletion of a node you hold, and costs one reference per node and twice the care in every operation.

munotes.in187

Practical 14: Doubly Linked Lists

Quick revision

  • Every node has prv and nxt. The head's prv and the tail's nxt are None.
  • An insertion means four link assignments, and the two special cases are at the head and the

tail. A program that makes three of them is wrong backwards only.

  • unlink(node) is four branches and no searching: O(1) given the node. That is the whole reason

for the structure.

  • Clear an unlinked node's links, or it points into a list it has left.
  • node_at searches from the nearer end, so the worst case is n over 2 rather than n.
  • Browser history and undo-redo are the same structure: a list with a cursor. Back and forward

move it; doing something new attaches after it and discards everything in front.

  • A sentinel node at the start removes the special case from undo.
  • The editor stores, for each action, what was done and the state before it, which is the simplest

correct undo.

  • Costs against a singly linked list: deletion at the tail and deletion of a held node go from

O(n) to O(1), and backward traversal becomes possible. The cost is one reference a node.

  • A doubly linked list is what sits under an LRU cache and under collections.deque.

Questions you should be able to answer

1. What does a doubly linked list have that a singly linked list does not? A second link in each node, pointing at the previous node. That allows backward traversal and lets a node the caller is holding be deleted in a constant number of steps.

2. How many link assignments does inserting a node between two others take? Four: the new node's two links, the previous node's nxt and the next node's prv. Missing the last one leaves a list that is right forwards and wrong backwards.

3. Why is deleting the tail O(1) here and O(n) in a singly linked list? Because the tail's predecessor is tail.prv, one step away. A singly linked list has to walk from the head to find it.

4. Is deletion O(1)? Given the node, yes. Finding the node by its value is still O(n); the constant time is in the unlinking, not the searching.

5. What are the disadvantages of a doubly linked list? One extra reference per node, so more memory, and every insertion and deletion has twice as many links to maintain, so more code and more chances to get it wrong.

munotes.in188

Practical 14: Doubly Linked Lists

6. In a browser history, what happens when you go back twice and then follow a new link? The pages that were in front of the cursor are discarded and the new page is attached after the cursor, so the forward button becomes unusable. Appending at the tail instead would leave a forward history the user should not have.

7. Why are browser history and undo-redo the same structure? Both are a doubly linked list with a cursor: back and undo move it one way, forward and redo the other, and a new page or a new action attaches after the cursor and throws away what was in front.

8. What is a sentinel node, and what does it buy here? A dummy node at the start that holds no real data. It means undo needs no special case for the beginning of the history: the sentinel is the node whose prv is None.

9. How can node_at be faster in a doubly linked list? By starting from whichever end is nearer: from the head for the first half and from the tail backwards for the second. The worst case becomes n over 2 instead of n.

Contents This chapter on its own page

munotes.in189

Chapter Twenty-Two

Practical 15: the Stack ADT

Syllabus topic Module 2, "Implementing and Using Stack ADT: Implement push, pop, peek using arrays or linked lists. Solve problems like delimiter matching or undo mechanism."

Aim

To implement the Stack abstract data type twice, over an array and over a linked list, and to use a stack for delimiter matching and for an undo mechanism.

What you need to know before you start

A stack is a collection in which the only item you can reach is the one put in most recently. It is LIFO: last in, first out.

The picture is a pile of plates. You add to the top and you take from the top, and to reach the bottom plate you must remove every plate above it.

The ADT: five operations and nothing else

OperationWhat it doesIf the stack is empty
push(x)put x on the topfine
pop()remove and return the top iteman error
peek()return the top item without removing itan error
is_empty()is there anything in itfine, returns True
len()how many itemsfine, returns 0

That is the whole contract. Notice what is not in it: there is no way to look at the third item, no way to search, and no way to iterate. A stack that lets you index into it is not a stack, and an examiner will say so. The restriction is the point: a structure that can do less is easier to reason about, and a great many problems need exactly this much.

pop and peek on an empty stack raise, for the reason set out in [Python for Data Structures: the Tools This Module Uses]: returning None would be indistinguishable from a stack whose top item is None.

Where stacks are, whether you built one or not

  • Function calls. Every call pushes a frame with the local variables and the return address; a

return pops it. That is why it is called the call stack, and why endless recursion gives a RecursionError.

  • Undo in every editor. MU's own second bullet.
  • Matching brackets, in every compiler and every editor that highlights them. MU's other

bullet.

  • Depth-first search, in [Practical 19: Graph Representations and Traversals], where a stack

is what makes it depth-first.

  • Evaluating expressions, which is the next chapter.
  • The back button, though that needs two stacks or the doubly linked list of

[Practical 14: Doubly Linked Lists].

The two implementations

class ArrayStack:
    """A stack over a Python list. The TOP is the END of the list,
    because appending and popping there are the cheap operations."""

    def __init__(self):
        self._items = []

    def push(self, item):
        self._items.append(item)

    def pop(self):
        if self.is_empty():
            raise IndexError("pop from an empty stack")
        return self._items.pop()

    def peek(self):
        if self.is_empty():
            raise IndexError("peek at an empty stack")
        return self._items[-1]

    def is_empty(self):
        return len(self._items) == 0

    def __len__(self):
        return len(self._items)

    def __repr__(self):
        return f"ArrayStack(bottom {self._items} top)"


class Node:
    def __init__(self, data, nxt=None):
        self.data = data
        self.nxt = nxt


class LinkedStack:
    """A stack over a singly linked list. The TOP is the HEAD,
    because prepending and removing there are the cheap operations."""

    def __init__(self):
        self._top = None
        self._count = 0

    def push(self, item):
        self._top = Node(item, self._top)
        self._count += 1

    def pop(self):
        if self._top is None:
            raise IndexError("pop from an empty stack")
        node = self._top
        self._top = node.nxt
        self._count -= 1
        return node.data

    def peek(self):
        if self._top is None:
            raise IndexError("peek at an empty stack")
        return self._top.data

    def is_empty(self):
        return self._top is None

    def __len__(self):
        return self._count

    def __repr__(self):
        items, here = [], self._top
        while here is not None:
            items.append(repr(here.data))
            here = here.nxt
        return "LinkedStack(top " + " ".join(items) + " bottom)"


def exercise(stack):
    """The SAME code drives both, because they are the same ADT."""
    print(f"  a new {type(stack).__name__}: empty?", stack.is_empty())
    for x in (10, 20, 30):
        stack.push(x)
        print(f"    push {x} ->", stack, "size", len(stack))
    print("    peek  ->", stack.peek(), "and the size is still", len(stack))
    print("    pop   ->", stack.pop(), "->", stack)
    print("    pop   ->", stack.pop(), "->", stack)
    print("    pop   ->", stack.pop(), "-> empty?", stack.is_empty())
    try:
        stack.pop()
    except IndexError as e:
        print("    one pop too many: IndexError:", e)


print("the same exercise over two different implementations")
print()
exercise(ArrayStack())
print()
exercise(LinkedStack())
munotes.in190

Practical 15: the Stack ADT

the same exercise over two different implementations

  a new ArrayStack: empty? True
    push 10 -> ArrayStack(bottom [10] top) size 1
    push 20 -> ArrayStack(bottom [10, 20] top) size 2
    push 30 -> ArrayStack(bottom [10, 20, 30] top) size 3
    peek  -> 30 and the size is still 3
    pop   -> 30 -> ArrayStack(bottom [10, 20] top)
    pop   -> 20 -> ArrayStack(bottom [10] top)
    pop   -> 10 -> empty? True
    one pop too many: IndexError: pop from an empty stack

  a new LinkedStack: empty? True
    push 10 -> LinkedStack(top 10 bottom) size 1
    push 20 -> LinkedStack(top 20 10 bottom) size 2
    push 30 -> LinkedStack(top 30 20 10 bottom) size 3
    peek  -> 30 and the size is still 3
    pop   -> 30 -> LinkedStack(top 20 10 bottom)
    pop   -> 20 -> LinkedStack(top 10 bottom)
    pop   -> 10 -> empty? True
    one pop too many: IndexError: pop from an empty stack

One exercise function, two stacks

That is the thing to notice, and it is worth a mark in the viva. exercise calls push, pop, peek, is_empty and len, and it does not know or care which class it was given. Both classes implement the same ADT, so both satisfy the same code.

The output differs in exactly one respect: the __repr__, because that prints the representation and the representation is the thing that differs. Every answer to every operation is identical.

munotes.in191

Practical 15: the Stack ADT

The choice of which end is the top

This is the design decision in each class, and getting it wrong makes a correct stack slow.

Where the top isWhy
ArrayStackthe end of the listappend and pop() are O(1) there; insert(0, x) and pop(0) are O(n)
LinkedStackthe head of the chainprepending and removing the head are O(1); the tail needs a walk

Both are the cheap end of their own structure, and they are opposite ends. A student who puts the top of an array stack at index 0 has written a stack that is correct and O(n) per operation, and [Practical 12: Singly Linked Lists] measured what that costs: about 99 times slower for 20000 operations.

Array against linked list, for a stack

ArrayStackLinkedStack
pushO(1) amortisedO(1) always
pop, peekO(1)O(1)
Memory per itemone reference, plus spare roomone item plus one link
Memory totalcan be up to twice what is neededexactly what is needed
A fixed maximum sizein C, yes, and overflow is possibleno, until memory runs out
Cache behaviourgood, the items are togetherpoor, the nodes are scattered

"O(1) amortised" is worth explaining, because an examiner may ask. A Python list occasionally has to move to a bigger block, which costs O(n) for that one append. It happens rarely enough that the average over many appends is constant, and that average is what "amortised" means. The linked list has no such moment: every push costs the same.

Overflow is the one thing this pair does not show, because Python lists grow. In C a stack is usually a fixed array with a top index, and then push must check top == MAX - 1 and report stack overflow, while pop checks top == -1 and reports stack underflow. Those two words are examination vocabulary, and the linked version cannot overflow at all, which is its main advantage.

MU's first application: matching delimiters

Every opening bracket must be closed, by the right kind of bracket, in the right order. A stack is the natural answer, and the reason is worth stating: the bracket that must be closed next is always the one opened most recently, which is the definition of LIFO.

PAIRS = {")": "(", "]": "[", "}": "{"}
OPENERS = set(PAIRS.values())


def check_delimiters(text):
    """Returns (True, '') or (False, why). One stack, one pass."""
    stack = []
    for at, ch in enumerate(text):
        if ch in OPENERS:
            stack.append((ch, at))
        elif ch in PAIRS:
            if not stack:
                return False, f"{ch!r} at position {at} closes nothing"
            opener, where = stack.pop()
            if opener != PAIRS[ch]:
                return False, (f"{ch!r} at position {at} does not match "
                               f"{opener!r} at position {where}")
    if stack:
        opener, where = stack[-1]
        return False, f"{opener!r} at position {where} is never closed"
    return True, ""


tests = [
    "a = (b + [c * d]) - {e}",
    "print(items[0])",
    "",
    "(a + [b)]",
    "a = (b + c",
    "a = b + c)",
    "([{}])",
    "{[(])}",
]

print(f"  {'text':<26}{'balanced':<10}why")
for t in tests:
    ok, why = check_delimiters(t)
    print(f"  {t!r:<26}{str(ok):<10}{why}")
munotes.in192

Practical 15: the Stack ADT

  text                      balanced  why
  'a = (b + [c * d]) - {e}' True
  'print(items[0])'         True
  ''                        True
  '(a + [b)]'               False     ')' at position 7 does not match '[' at position 5
  'a = (b + c'              False     '(' at position 4 is never closed
  'a = b + c)'              False     ')' at position 9 closes nothing
  '([{}])'                  True
  '{[(])}'                  False     ']' at position 3 does not match '(' at position 2

The algorithm in four lines

  1. An opener is pushed, with its position.
  2. A closer pops. If the stack was empty, this closer closes nothing.
  3. If the popped opener is not the matching kind, the two do not match.
  4. At the end, anything left on the stack was never closed.

All four failure modes appear in the run, and a complete answer reports all four:

InputThe fault
(a + [b)]crossed: the ) meets a [
a = (b + cnever closed: the ( is still on the stack at the end
a = b + c)closes nothing: the stack was empty
{[(])}crossed again, and note it is caught at the ], not later

The position is pushed with the bracket, which is why the messages can name where. A matcher that pushes only the character can say that something is wrong and not where, and an editor that cannot point at the mistake is not much use.

The empty string is balanced, and it is in the tests on purpose. A program that reports an error for it has an off-by-one somewhere, and the empty case is the cheapest test in any exercise in this module.

A dictionary from closer to opener is better than three ifs. PAIRS[ch] is one lookup and adding another kind of bracket is one line. Three chained conditions are three chances to get a character wrong, and every real language has more than three kinds of bracket to match.

MU's second application: an undo mechanism

[Practical 14: Doubly Linked Lists] built undo and redo with a cursor on a list. If redo is not needed, a single stack is enough and far simpler.

class Editor:
    """Undo with a stack. Each entry is what to do to go back."""

    def __init__(self):
        self.text = ""
        self.undo_stack = []

    def type(self, s):
        self.text += s
        self.undo_stack.append(("remove_last", len(s)))

    def delete_last(self, n):
        if n > len(self.text):
            raise ValueError(f"there are only {len(self.text)} characters")
        removed = self.text[-n:]
        self.text = self.text[:-n]
        self.undo_stack.append(("put_back", removed))

    def replace_all(self, old, new):
        before = self.text
        self.text = self.text.replace(old, new)
        self.undo_stack.append(("restore", before))

    def undo(self):
        if not self.undo_stack:
            raise IndexError("there is nothing to undo")
        what, arg = self.undo_stack.pop()
        if what == "remove_last":
            self.text = self.text[:-arg]
        elif what == "put_back":
            self.text = self.text + arg
        else:
            self.text = arg
        return what


e = Editor()
e.type("Computer Science ")
e.type("Practical 3")
e.replace_all("Practical", "Prac.")
e.delete_last(6)

print("  the text now      :", repr(e.text))
print("  actions on the stack:", len(e.undo_stack))
while e.undo_stack:
    what = e.undo()
    print(f"  undo {what:<12} -> {e.text!r}")
try:
    e.undo()
except IndexError as ex:
    print("  one undo too many : IndexError:", ex)
munotes.in193

Practical 15: the Stack ADT

  the text now      : 'Computer Science P'
  actions on the stack: 4
  undo put_back     -> 'Computer Science Prac. 3'
  undo restore      -> 'Computer Science Practical 3'
  undo remove_last  -> 'Computer Science '
  undo remove_last  -> ''
  one undo too many : IndexError: there is nothing to undo

The idea: push the reverse, not the action

Each entry on the stack says how to go back, not what was done:

The actionWhat is pushedWhy
type s("remove_last", len(s))undoing it means removing that many characters
delete the last n("put_back", the text removed)undoing it means putting them back
replace all("restore", the whole text before)a replacement cannot be reversed from the arguments

The third row is the interesting one and the reason the run includes it. "Replace all Practical with Prac." cannot be undone by replacing Prac. with Practical, because the text may have contained Prac. already, and then the undo would change something the user never touched. When an action cannot be reversed from its arguments, store the state before it. That is the general rule, and it is why real editors keep snapshots as well as actions.

The stack comes off in the right order for nothing. The run undoes four actions and each one is the most recent remaining, because that is what a stack is. No sorting, no timestamps, no book-keeping: LIFO is exactly the semantics of undo.

Redo needs a second stack, and it is one line in each method: undo pushes what it undid on to a redo stack, redo pops from it and pushes back on to the undo stack, and any new action clears the redo stack. That last line is the same rule as the cursor version in [Practical 14: Doubly Linked Lists]: doing something new discards what was in front.

Procedure

  1. Write both stack classes and the exercise function. Run it and confirm every answer is the
munotes.in194

Practical 15: the Stack ADT

same for both.

  1. Change ArrayStack so that the top is index 0, using insert(0, x) and pop(0). Confirm it

is still correct, then time 100000 pushes on both versions.

  1. Add a size limit to ArrayStack and make push raise on overflow. That is the C version,

and the word is worth having.

  1. Write the delimiter matcher. Add angle brackets to PAIRS and confirm it takes one line.
  2. Run the matcher over one of your own Python files, read as a string, and note what it says

about the brackets inside string literals. That is a real limitation and worth a sentence in the journal.

  1. Write the editor. Add a redo stack and the line that clears it on a new action.
  2. Write is_palindrome(word) using a stack: push every character, then pop them and compare. It

is a standard examination question and it is four lines.

Result

The Stack abstract data type was implemented twice, over a Python list with the top at the end and over a singly linked list with the top at the head, and one exercise function was shown to drive both without change, every answer identical. Both give push, pop and peek in constant time by choosing the cheap end of their own structure. A delimiter matcher was built on a stack and reported all four kinds of fault, naming the position in each case. An undo mechanism was built on a single stack, storing the reverse of each action, and the case of an action that cannot be reversed from its arguments was handled by storing the state before it.

Where marks are lost

  • Putting the top of an array stack at index 0. Correct and O(n) per operation.
  • pop returning None on an empty stack instead of raising.
  • No peek, or a peek that removes the item.
  • Letting a caller index into the stack. A stack has five operations and indexing is not one

of them.

  • Three ifs instead of a dictionary in the matcher, and then missing a bracket kind.
  • Reporting only that the delimiters are unbalanced, not which one and where.
  • Not checking the stack at the end of the matcher, so a = (b + c passes.
  • Not handling the empty input.
  • Undoing by re-deriving the action where the action is not reversible from its arguments, as

with replace-all.

  • Not knowing the words overflow and underflow, which is what the C version is examined on.

For the journal

Write the aim, MU's own wording, the five operations of the ADT with what each does to an empty stack, and the picture of a pile of plates. Then both implementations and the one run that drives both, and one sentence saying that the outputs are identical except for the representation, because that sentence is the answer to what an ADT is. Then the matcher with all eight test lines, because the four failure modes are what make the answer complete, and the undo with its four actions undone in order. The conclusion: a stack is LIFO with five operations, it can be built over an array or a chain by choosing the cheap end of each, and the problems it solves are the ones where the thing to deal with next is always the thing seen most recently.

munotes.in195

Practical 15: the Stack ADT

Quick revision

  • A stack is LIFO: last in, first out. Five operations: push, pop, peek, is_empty, len. Nothing

else.

  • pop and peek on an empty stack raise. Returning None cannot be told from a stored None.
  • Over an array the top is the end; over a linked list the top is the head. Both are the

cheap end of that structure, and they are opposite ends.

  • ArrayStack.push is O(1) amortised, because the list occasionally moves to a bigger block.

LinkedStack.push is O(1) always.

  • In C a fixed-array stack can overflow; popping an empty one is underflow. A linked stack

cannot overflow.

  • One function can drive both implementations without change. That is what an abstract data type

means.

  • Delimiter matching: push openers with their position, pop on a closer, check the kind matches,

and check the stack is empty at the end. Four failure modes, and a complete answer reports all four.

  • Use a dictionary from closer to opener, not a chain of ifs.
  • Undo: push the reverse of each action. Where an action cannot be reversed from its

arguments, push the state before it.

  • Redo needs a second stack, and a new action clears it.
  • A stack is also the call stack, depth-first search, and expression evaluation.

Questions you should be able to answer

1. What does LIFO mean and what are the five operations of a stack? Last in, first out: the only reachable item is the one added most recently. Push, pop, peek, is_empty and a size.

2. Where is the top in an array implementation and in a linked implementation, and why? At the end of the array and at the head of the chain. Those are the cheap ends: appending and popping the end of a Python list are O(1), and prepending and removing the head of a chain are O(1).

3. What happens if you put the top of an array stack at index 0? Every push and pop then shifts every other item, so each operation is O(n) instead of O(1). The stack is still correct and it is unusably slow for large sizes.

munotes.in196

Practical 15: the Stack ADT

4. Why must pop on an empty stack raise rather than return None? Because None may be a value somebody pushed, so the caller could not tell an empty stack from one whose top is None.

5. What is stack overflow, and can a linked stack suffer it? Overflow is pushing on to a stack that has reached its fixed capacity, which happens in a C array implementation. A linked stack allocates a node per push, so it cannot overflow until the machine runs out of memory.

6. Why is a stack the right structure for matching brackets? Because the bracket that has to be closed next is always the one opened most recently, which is exactly LIFO.

7. Name the four things a delimiter matcher must detect. A closer of the wrong kind, a closer when nothing is open, an opener that is never closed, and, in passing, that the empty input is balanced rather than an error.

8. Why does an undo stack store the state before a replace-all rather than the replacement? Because reversing the replacement would also change text that already contained the new string before the action, which the user never touched. When an action cannot be reversed from its arguments, the state before it is stored.

9. What does "O(1) amortised" mean? That a single operation may occasionally cost more, here when the list moves to a bigger block, but rarely enough that the average cost over many operations is constant.

Contents This chapter on its own page

munotes.in197

Chapter Twenty-Three

Practical 15 continued: Prefix to Postfix, and Evaluating It

Syllabus topic Module 2, "Convert expressions from prefix to postfix and evaluate them."

Aim

To convert an expression from prefix to postfix using a stack, to evaluate a postfix expression, and to convert infix to postfix with operator precedence.

What you need to know before you start

The three notations differ only in where the operator goes.

NotationAlso calledExampleBrackets needed
Infixordinary( 3 + 4 ) * ( 10 - 6 )yes
PrefixPolish* + 3 4 - 10 6no
Postfixreverse Polish3 4 + 10 6 - *no

Infix needs brackets and rules of precedence, because 2 + 3 4 is ambiguous until you know that binds tighter than +. Prefix and postfix need neither: the position of the operator says exactly which operands it applies to, so there is one reading and only one.

That is why a compiler converts your infix source into postfix, and why a calculator evaluates postfix: postfix can be evaluated in one left-to-right pass with a single stack, and infix cannot.

Reading a prefix or postfix expression

The rule for postfix: go left to right; every operand waits, and every operator takes the two most recent waiting values.

3 4 + 10 6 - *
3, 4 wait.  + takes them: 7 waits.
10, 6 wait. - takes them: 4 waits, behind the 7.
* takes 7 and 4: 28.

The rule for prefix is the mirror: go right to left, and every operator takes the two most recent waiting values. That mirror is why the conversion below reads the input backwards.

The order of the two operands matters for -, / and ^. 10 6 - is 4 and 6 10 - is -4. In the evaluator the first value popped is the second operand, and getting that round the wrong way is the commonest single error in this exercise. The program names them b then a for exactly that reason.

Prefix to postfix, and evaluating the result

OPERATORS = set("+-*/^")


def prefix_to_postfix(prefix, trace=False):
    """Read the prefix expression from RIGHT to LEFT, with one stack
    holding postfix strings."""
    stack = []
    if trace:
        print(f"    {'token':<7}{'action':<28}stack after")
    for token in reversed(prefix.split()):
        if token in OPERATORS:
            if len(stack) < 2:
                raise ValueError(f"{token!r} has no two operands")
            a = stack.pop()                 # the NEARER operand
            b = stack.pop()
            joined = f"{a} {b} {token}"
            stack.append(joined)
            action = "pop two, push their postfix"
        else:
            stack.append(token)
            action = "an operand: push it"
        if trace:
            print(f"    {token:<7}{action:<28}{stack}")
    if len(stack) != 1:
        raise ValueError("the expression is not well formed")
    return stack[0]


def evaluate_postfix(postfix, trace=False):
    stack = []
    if trace:
        print(f"    {'token':<7}{'action':<30}stack after")
    for token in postfix.split():
        if token in OPERATORS:
            if len(stack) < 2:
                raise ValueError(f"{token!r} has no two operands")
            b = stack.pop()                 # the SECOND operand
            a = stack.pop()                 # the first
            if token == "+":
                r = a + b
            elif token == "-":
                r = a - b
            elif token == "*":
                r = a * b
            elif token == "/":
                if b == 0:
                    raise ZeroDivisionError("division by zero in the expression")
                r = a / b
            else:
                r = a ** b
            stack.append(r)
            action = f"{a} {token} {b} = {r}"
        else:
            stack.append(float(token) if "." in token else int(token))
            action = "an operand: push it"
        if trace:
            print(f"    {token:<7}{action:<30}{stack}")
    if len(stack) != 1:
        raise ValueError("the expression is not well formed")
    return stack[0]


print("prefix to postfix, step by step")
p = "* + 3 4 - 10 6"
print("  prefix :", p)
post = prefix_to_postfix(p, trace=True)
print("  postfix:", post)

print()
print("evaluating that postfix expression, step by step")
value = evaluate_postfix(post, trace=True)
print("  value  :", value)
print("  and by hand: (3 + 4) * (10 - 6) is 7 * 4, which is 28")

print()
print("four more, converted and evaluated")
for p in ("+ 1 2",
          "- + 7 3 2",
          "/ * 6 5 3",
          "^ 2 + 1 2"):
    q = prefix_to_postfix(p)
    print(f"  prefix {p:<12} postfix {q:<16} value {evaluate_postfix(q)}")

print()
print("the things that must be refused")
for bad, why in (("+ 1", "an operator with only one operand"),
                 ("1 2", "two operands and no operator"),
                 ("/ 5 0", "division by zero")):
    try:
        evaluate_postfix(prefix_to_postfix(bad))
    except (ValueError, ZeroDivisionError) as e:
        print(f"  {bad!r:<10} {why:<34} {type(e).__name__}: {e}")
munotes.in198

Practical 15 continued: Prefix to Postfix, and Evaluating It

prefix to postfix, step by step
  prefix : * + 3 4 - 10 6
    token  action                      stack after
    6      an operand: push it         ['6']
    10     an operand: push it         ['6', '10']
    -      pop two, push their postfix ['10 6 -']
    4      an operand: push it         ['10 6 -', '4']
    3      an operand: push it         ['10 6 -', '4', '3']
    +      pop two, push their postfix ['10 6 -', '3 4 +']
    *      pop two, push their postfix ['3 4 + 10 6 - *']
  postfix: 3 4 + 10 6 - *

evaluating that postfix expression, step by step
    token  action                        stack after
    3      an operand: push it           [3]
    4      an operand: push it           [3, 4]
    +      3 + 4 = 7                     [7]
    10     an operand: push it           [7, 10]
    6      an operand: push it           [7, 10, 6]
    -      10 - 6 = 4                    [7, 4]
    *      7 * 4 = 28                    [28]
  value  : 28
  and by hand: (3 + 4) * (10 - 6) is 7 * 4, which is 28

four more, converted and evaluated
  prefix + 1 2        postfix 1 2 +            value 3
  prefix - + 7 3 2    postfix 7 3 + 2 -        value 8
  prefix / * 6 5 3    postfix 6 5 * 3 /        value 10.0
  prefix ^ 2 + 1 2    postfix 2 1 2 + ^        value 8

the things that must be refused
  '+ 1'      an operator with only one operand  ValueError: '+' has no two operands
  '1 2'      two operands and no operator       ValueError: the expression is not well formed
  '/ 5 0'    division by zero                   ZeroDivisionError: division by zero in the expression
munotes.in199

Practical 15 continued: Prefix to Postfix, and Evaluating It

The algorithm in three lines

  1. Read the prefix expression from right to left.
  2. An operand is pushed as it stands.
  3. An operator pops two, and pushes first second operator as one string.

The stack in this algorithm holds strings, not numbers: partly built postfix expressions. At the end there is exactly one, and that is the answer.

Why right to left. In prefix the operator comes before its operands, so reading forwards you meet an operator before you know what it applies to. Reading backwards you meet the operands first, and by the time the operator arrives, its two operands are the two most recent things on the stack. Reading forwards would need recursion or a second pass.

Which popped value is which. The stack is read backwards, so the first value popped is the one that was nearer the operator, which is its first operand. Hence a = stack.pop() then b = stack.pop() and the result is a b operator. Swap those and - 10 6 converts to 6 10 -, which evaluates to -4 instead of 4. The trace above is where to check it: at the -, the stack held ['6', '10'] and the result was 10 6 -.

Evaluating postfix

The same shape with numbers instead of strings, and the operand order reversed:

b = stack.pop()        # the SECOND operand
a = stack.pop()        # the first
stack.append(a - b)    # not b - a

Reading forwards this time, so the first value popped is the one pushed most recently, which is the second operand. That is the opposite of the conversion, and the two traces on the page are what make it clear: in the conversion at the -, the stack was ['6', '10']; in the evaluation at the -, it was [7, 10, 6] and the answer was 10 - 6.

The program checks the answer against arithmetic. (3 + 4) * (10 - 6) is 7 times 4, which is 28, and the page prints both the program's 28 and that reasoning. A conversion that silently reversed the subtraction would give 7 times -4, which is -28, and the check would catch it.

Note the / result is a float. 6 5 * 3 / gives 10.0 and not 10, because Python's / always gives a float. For integer division the operator would be //, and a calculator that must print 10 has to say so explicitly. That is worth a line in the journal, because the examiner's model answer may show 10.

munotes.in200

Practical 15 continued: Prefix to Postfix, and Evaluating It

What must be refused

Three faults, and all three are shown firing:

InputThe faultWhat is raised
+ 1an operator with fewer than two operandsValueError
1 2operands with no operator, so more than one is left at the endValueError
/ 5 0division by zeroZeroDivisionError

The end-of-expression check is the one students leave out. After the loop the stack must hold exactly one value. If it holds more, the expression had too many operands; if the loop ran out of operands for an operator, that was caught earlier. Both checks are two lines and both are marked.

Infix to postfix, which is where precedence comes in

MU's bullet asks for prefix to postfix, and infix to postfix is worth having as well: it is the conversion a compiler actually does, it is at least as likely in an examination, and it is the only one of the three that needs a precedence table.

The algorithm is Dijkstra's shunting yard, and the name is worth knowing.

PRECEDENCE = {"+": 1, "-": 1, "*": 2, "/": 2, "^": 3}
RIGHT_ASSOCIATIVE = {"^"}


def infix_to_postfix(infix, trace=False):
    """Dijkstra's shunting-yard algorithm: one stack, one pass, left to right."""
    out, stack = [], []
    if trace:
        print(f"    {'token':<7}{'output so far':<24}stack")
    for token in infix.split():
        if token == "(":
            stack.append(token)
        elif token == ")":
            while stack and stack[-1] != "(":
                out.append(stack.pop())
            if not stack:
                raise ValueError("a ')' with no '(' before it")
            stack.pop()                       # throw the '(' away
        elif token in PRECEDENCE:
            while (stack and stack[-1] != "("
                   and (PRECEDENCE[stack[-1]] > PRECEDENCE[token]
                        or (PRECEDENCE[stack[-1]] == PRECEDENCE[token]
                            and token not in RIGHT_ASSOCIATIVE))):
                out.append(stack.pop())
            stack.append(token)
        else:
            out.append(token)
        if trace:
            print(f"    {token:<7}{' '.join(out):<24}{stack}")

    while stack:
        top = stack.pop()
        if top == "(":
            raise ValueError("a '(' that is never closed")
        out.append(top)
    return " ".join(out)


print("infix to postfix, step by step")
i = "( 3 + 4 ) * ( 10 - 6 )"
print("  infix  :", i)
print("  postfix:", infix_to_postfix(i, trace=True))

print()
print("precedence and brackets, seven expressions")
for i in ("2 + 3 * 4",
          "( 2 + 3 ) * 4",
          "2 * 3 + 4",
          "2 + 3 - 4",
          "2 ^ 3 ^ 2",
          "( 2 ^ 3 ) ^ 2",
          "2 * ( 3 + 4 ) / 7"):
    print(f"  {i:<22} -> {infix_to_postfix(i)}")

print()
print("the two bracket faults")
for bad in ("( 2 + 3", "2 + 3 )"):
    try:
        infix_to_postfix(bad)
    except ValueError as e:
        print(f"  {bad!r:<12} ValueError: {e}")
munotes.in201

Practical 15 continued: Prefix to Postfix, and Evaluating It

infix to postfix, step by step
  infix  : ( 3 + 4 ) * ( 10 - 6 )
    token  output so far           stack
    (                              ['(']
    3      3                       ['(']
    +      3                       ['(', '+']
    4      3 4                     ['(', '+']
    )      3 4 +                   []
    *      3 4 +                   ['*']
    (      3 4 +                   ['*', '(']
    10     3 4 + 10                ['*', '(']
    -      3 4 + 10                ['*', '(', '-']
    6      3 4 + 10 6              ['*', '(', '-']
    )      3 4 + 10 6 -            ['*']
  postfix: 3 4 + 10 6 - *

precedence and brackets, seven expressions
  2 + 3 * 4              -> 2 3 4 * +
  ( 2 + 3 ) * 4          -> 2 3 + 4 *
  2 * 3 + 4              -> 2 3 * 4 +
  2 + 3 - 4              -> 2 3 + 4 -
  2 ^ 3 ^ 2              -> 2 3 2 ^ ^
  ( 2 ^ 3 ) ^ 2          -> 2 3 ^ 2 ^
  2 * ( 3 + 4 ) / 7      -> 2 3 4 + * 7 /

the two bracket faults
  '( 2 + 3'    ValueError: a '(' that is never closed
  '2 + 3 )'    ValueError: a ')' with no '(' before it

The four rules

The tokenWhat to do
an operandsend it straight to the output
(push it
)pop to the output until a ( appears, then discard the (
an operatorpop operators of higher or equal precedence to the output, then push it

And at the end, pop everything left. A ( still on the stack means it was never closed.

Precedence: ^ is 3, and / are 2, + and - are 1. That is what makes 2 + 3 4 come out as 2 3 4 +: when the arrives, the + on the stack has lower precedence, so it stays, and the * goes on top of it.

Associativity is the subtle part, and the run shows it. 2 ^ 3 ^ 2 gives 2 3 2 ^ ^, which evaluates as 2 to the power of (3 to the power of 2), which is 2 to the 9, which is 512. That is right: exponentiation is right associative. Every other operator here is left associative, so 2 + 3 - 4 gives 2 3 + 4 -, which is (2 + 3) - 4.

The one condition in the program that does this is:

or (PRECEDENCE[stack[-1]] == PRECEDENCE[token]
    and token not in RIGHT_ASSOCIATIVE)

An operator of equal precedence is popped only if the new operator is left associative. Drop the and and 2 ^ 3 ^ 2 converts to 2 3 ^ 2 ^, which is (2 cubed) squared, or 64, not 512. The run prints both forms, 2 ^ 3 ^ 2 and ( 2 ^ 3 ) ^ 2, so the difference can be read off the page.

munotes.in202

Practical 15 continued: Prefix to Postfix, and Evaluating It

The brackets never appear in the output. They were only ever there to overrule precedence, and once the operators are in the right order they have nothing left to say. That is the point of postfix in one sentence.

The three conversions, side by side

ConversionReadThe stack holdsBrackets
Prefix to postfixright to leftpartly built postfix stringsnone to handle
Infix to postfixleft to rightoperators and (handled by rules 2 and 3
Evaluate postfixleft to rightnumbersnone

Prefix to postfix can also be done by reversing the infix method, and it can be done with recursion, and it can be done by building an expression tree and taking its post-order traversal. That last route is the link to [Practical 17: Binary Search Trees and Tree Traversals]: prefix is the pre-order traversal of an expression tree, postfix is its post-order traversal, and infix is its in-order traversal. That sentence is worth four marks and it is the reason these two topics sit next to each other on the syllabus.

Procedure

  1. Write the first program and run it. Check the trace of * + 3 4 - 10 6 against your own

working on paper.

  1. Swap a and b in the conversion and run it again. - 10 6 now converts to 6 10 - and the

value becomes -4.

  1. Swap a and b in the evaluator instead, and note that the same wrong answer appears from the

other end.

  1. Convert + * 2 3 / 8 4 by hand, then check it, then evaluate it.
  2. Write the shunting-yard program. Convert 2 ^ 3 ^ 2 and evaluate it with the first program:

the answer should be 512.

  1. Remove the associativity condition and confirm that the answer becomes 64.
  2. Add unary minus, so that - 5 means negative five. It is harder than it looks, and saying

why in the journal is worth more than making it work: the same symbol now has two meanings and two precedences, and the converter has to tell them apart from what came before it.

Result

A prefix expression was converted to postfix with one stack, reading right to left, and the conversion was traced token by token. The postfix expression was evaluated with a second stack reading left to right, giving 28 for ( 3 + 4 ) * ( 10 - 6 ), which agrees with the arithmetic done by hand. The order of the two popped operands was shown to matter, and to be opposite in the two algorithms. Three malformed expressions were refused with the appropriate exceptions. Infix was then converted to postfix by the shunting-yard algorithm with a precedence table, and right associativity of exponentiation was demonstrated by 2 ^ 3 ^ 2 converting to 2 3 2 ^ ^ and ( 2 ^ 3 ) ^ 2 to 2 3 ^ 2 ^.

munotes.in203

Practical 15 continued: Prefix to Postfix, and Evaluating It

Where marks are lost

  • Reading prefix from left to right. It has to be read backwards, or the operator arrives

before its operands.

  • Popping the operands in the wrong order, so subtraction and division come out negated or

inverted. It is the opposite order in the two algorithms.

  • Not checking that exactly one value is left at the end.
  • Not checking that an operator has two operands before popping.
  • Leaving the brackets in the postfix output.
  • Popping an equal-precedence operator for ^, which makes exponentiation left associative

and gives 64 where the answer is 512.

  • Forgetting to discard the ( after a ), so it reaches the output.
  • Not reporting an unclosed ( at the end.
  • Showing no trace. The examiner marks the working, and the stack after each token is the

working.

For the journal

Write the aim, MU's own wording, and the table of the three notations with one expression written in all three. Then the conversion algorithm in three numbered lines, the program, and the full trace, because the trace is what is marked. Then the evaluation with its trace and the hand check that 7 times 4 is 28. Then the shunting-yard program with the table of seven expressions, and the two forms of 2 ^ 3 ^ 2 side by side with their values, 512 and 64. Close with the sentence about expression trees: prefix, infix and postfix are the pre-order, in-order and post-order traversals of the same tree. The conclusion: postfix needs no brackets because the operator's position fixes its operands, which is why it can be evaluated in one pass with one stack.

Quick revision

  • Infix needs brackets and precedence; prefix and postfix need neither, because the operator's

position says which operands it takes.

  • Postfix is evaluated in one left-to-right pass with one stack: operands wait, an operator takes

the two most recent.

  • Prefix to postfix: read right to left, push operands, and on an operator pop two and push

first second operator.

  • In the conversion, the first value popped is the first operand. In the evaluation, the first
munotes.in204

Practical 15 continued: Prefix to Postfix, and Evaluating It

value popped is the second operand. They are opposite, and -, / and ^ are where it shows.

  • At the end of either algorithm the stack must hold exactly one value.
  • Infix to postfix is the shunting yard: operands out, ( pushed, ) pops to the (, an

operator pops those of higher or equal precedence and is then pushed.

  • Precedence: ^ 3, * and / 2, + and - 1.
  • ^ is right associative, so an equal-precedence operator is not popped for it. 2 ^ 3 ^ 2

is 2 3 2 ^ ^, which is 512; ( 2 ^ 3 ) ^ 2 is 2 3 ^ 2 ^, which is 64.

  • Brackets never appear in the postfix output.
  • Prefix, infix and postfix are the pre-order, in-order and post-order traversals of the

expression tree.

Questions you should be able to answer

1. Why do prefix and postfix need no brackets? Because the position of the operator determines which operands it applies to, so the expression has exactly one reading. Infix is ambiguous without precedence rules and brackets.

2. Why is a prefix expression read from right to left? Because the operator comes before its operands, so reading forwards you meet an operator before you know what it applies to. Read backwards, the two operands are already on the stack when the operator arrives.

3. In the evaluator, which of the two popped values is the second operand? The first one popped, because it was pushed most recently. So a is popped second and the result is a - b, not b - a.

4. Convert + 3 4 - 10 6 to postfix and evaluate it. 3 4 + 10 6 - , which is 7 times 4, which is 28.

5. What two checks must be made for a malformed expression? That an operator has two operands available when it is reached, and that exactly one value remains on the stack at the end.

6. Convert 2 + 3 4 to postfix, and say why. 2 3 4 +. When the arrives, the + on the stack has lower precedence, so it is not popped; the goes above it and comes off first.

7. What is the postfix form of 2 ^ 3 ^ 2, and what is its value? 2 3 2 ^ ^, which is 2 to the power of 9, so 512. Exponentiation is right associative, so an equal-precedence operator is not popped for it. ( 2 ^ 3 ) ^ 2 gives 2 3 ^ 2 ^, which is 64.

8. Where do the brackets go in the postfix output? Nowhere. They existed only to overrule precedence, and once the operators are in the right order they carry no information.

munotes.in205

Practical 15 continued: Prefix to Postfix, and Evaluating It

9. What is the relationship between the three notations and a binary tree? They are the three depth-first traversals of the expression tree: prefix is pre-order, infix is in-order, and postfix is post-order.

Contents This chapter on its own page

munotes.in206

Chapter Twenty-Four

Practical 16: Queues and Circular Queues

Syllabus topic Module 2, "Understanding Queues and Circular Queues: Develop linear and circular queues to simulate task scheduling. Perform enqueue and dequeue with wrap-around logic. Discuss memory utilization in linear vs circular queues."

Aim

To develop a linear queue and a circular queue in a fixed array, to perform enqueue and dequeue with wrap-around, to use the circular queue as a task scheduler, and to compare the memory the two use.

What you need to know before you start

A queue is FIFO: first in, first out. Items join at the rear and leave from the front, and nobody is overtaken. It is the queue at a bank counter, and it is the opposite discipline from the stack of [Practical 15: the Stack ADT].

StackQueue
DisciplineLIFO, last in first outFIFO, first in first out
Add atthe topthe rear
Remove fromthe topthe front
Operationspush, pop, peekenqueue, dequeue, peek
One end or twoonetwo
Used forundo, brackets, recursion, depth-first searchscheduling, printing, buffering, breadth-first search

The five operations

OperationWhat it doesIf the queue is empty
enqueue(x)add x at the rearfine, unless the queue is full
dequeue()remove and return the front iteman error
peek()the front item, without removing itan error
is_empty()is there anything in itfine
is_full()is there roomfine

is_full is new. A stack over a Python list never fills up, but a queue in this exercise lives in a fixed array, because that is where the interesting problem is, and a fixed array can fill.

The linear queue, and why it wastes space

The obvious implementation keeps two indices that only ever move forward: front where the next item will leave and rear where the last one arrived.

class LinearQueue:
    """A queue in a FIXED array, with front and rear that only move FORWARD.
    This is the version that wastes space, and it is worth building once."""

    def __init__(self, size):
        self.size = size
        self.items = [None] * size
        self.front = 0
        self.rear = -1                     # nothing in it yet
        self.count = 0

    def is_empty(self):
        return self.count == 0

    def is_full(self):
        """FULL means the rear has reached the end, NOT that it holds `size`."""
        return self.rear == self.size - 1

    def enqueue(self, item):
        if self.is_full():
            raise IndexError("the queue is full (the rear is at the end)")
        self.rear += 1
        self.items[self.rear] = item
        self.count += 1

    def dequeue(self):
        if self.is_empty():
            raise IndexError("the queue is empty")
        item = self.items[self.front]
        self.items[self.front] = None
        self.front += 1
        self.count -= 1
        return item

    def __repr__(self):
        cells = " ".join("." if x is None else str(x) for x in self.items)
        return f"[{cells}]  front {self.front} rear {self.rear} count {self.count}"


q = LinearQueue(5)
print("a linear queue of 5 cells")
print("  new            :", q)

for x in (10, 20, 30, 40, 50):
    q.enqueue(x)
print("  five enqueues  :", q, "full?", q.is_full())

for _ in range(3):
    q.dequeue()
print("  three dequeues :", q)

print("  three cells are free, and now:")
try:
    q.enqueue(60)
except IndexError as e:
    print("    enqueue(60): IndexError:", e)

print()
print("  that is the defect: 3 of 5 cells are free and the queue says it is full,")
print("  because the rear can only move forward and has reached the end.")
munotes.in207

Practical 16: Queues and Circular Queues

a linear queue of 5 cells
  new            : [. . . . .]  front 0 rear -1 count 0
  five enqueues  : [10 20 30 40 50]  front 0 rear 4 count 5 full? True
  three dequeues : [. . . 40 50]  front 3 rear 4 count 2
  three cells are free, and now:
    enqueue(60): IndexError: the queue is full (the rear is at the end)

  that is the defect: 3 of 5 cells are free and the queue says it is full,
  because the rear can only move forward and has reached the end.

Read the last two lines of that run. The array has five cells. Two hold items. Three are empty. And enqueue(60) fails.

The reason is in is_full:

def is_full(self):
    return self.rear == self.size - 1

The rear has reached the last cell, so there is nowhere for it to go. The three free cells are at the front, behind front, and a rear that only moves forward can never reach them. This is called queue overflow with free space or simply the rightward drift of a linear queue, and it is the defect the exercise exists to fix.

Two bad answers and one good one:

CureWhat it costs
Shift everything down when the rear fills upO(n) on that dequeue, and unpredictable
Shift on every dequeue, so front is always 0O(n) on every dequeue, which is worse
Wrap the indices round: the circular queueO(1) always, and three characters of code

A Python list with pop(0) is the second of those, and it is what most students write first. It is correct and it is O(n) per dequeue, and [Practical 12: Singly Linked Lists] measured what that costs: 20000 of them were about 99 times slower than the cheap operation.

The circular queue

The whole difference is the modulo operator. Instead of rear = rear + 1, which walks off the end:

self.rear = (self.rear + 1) % self.size
self.front = (self.front + 1) % self.size

With five cells, an index runs 0, 1, 2, 3, 4, 0, 1, 2, and so on for ever. The array becomes a ring.

class CircularQueue:
    """The same fixed array, with the indices wrapping round with %.
    A count is kept, so full and empty can be told apart."""

    def __init__(self, size):
        self.size = size
        self.items = [None] * size
        self.front = 0
        self.rear = -1
        self.count = 0

    def is_empty(self):
        return self.count == 0

    def is_full(self):
        return self.count == self.size

    def enqueue(self, item):
        if self.is_full():
            raise IndexError("the queue is full (all cells hold an item)")
        self.rear = (self.rear + 1) % self.size        # THE wrap
        self.items[self.rear] = item
        self.count += 1

    def dequeue(self):
        if self.is_empty():
            raise IndexError("the queue is empty")
        item = self.items[self.front]
        self.items[self.front] = None
        self.front = (self.front + 1) % self.size      # and THE other wrap
        self.count -= 1
        return item

    def peek(self):
        if self.is_empty():
            raise IndexError("the queue is empty")
        return self.items[self.front]

    def __len__(self):
        return self.count

    def __repr__(self):
        cells = " ".join("." if x is None else str(x) for x in self.items)
        return f"[{cells}]  front {self.front} rear {self.rear} count {self.count}"


q = CircularQueue(5)
print("a circular queue of 5 cells")
print("  new            :", q)

for x in (10, 20, 30, 40, 50):
    q.enqueue(x)
print("  five enqueues  :", q, "full?", q.is_full())

for _ in range(3):
    q.dequeue()
print("  three dequeues :", q)

for x in (60, 70, 80):
    q.enqueue(x)
print("  three more in   :", q)
print("  and the 60 sits at index 0, because (4 + 1) % 5 is 0")

print()
print("  now it really is full:")
try:
    q.enqueue(90)
except IndexError as e:
    print("    enqueue(90): IndexError:", e)

print()
print("  emptying it, in order:")
while not q.is_empty():
    print(f"    dequeue -> {q.dequeue():<3} {q}")
try:
    q.dequeue()
except IndexError as e:
    print("    one dequeue too many: IndexError:", e)

print()
print("the full-against-empty ambiguity, if no count were kept")
r = CircularQueue(3)
print("  empty       : front", r.front, "rear", r.rear,
      "-> (rear + 1) % size is", (r.rear + 1) % r.size, "which equals front?",
      (r.rear + 1) % r.size == r.front)
for x in (1, 2, 3):
    r.enqueue(x)
print("  and now full: front", r.front, "rear", r.rear,
      "-> (rear + 1) % size is", (r.rear + 1) % r.size, "which equals front?",
      (r.rear + 1) % r.size == r.front)
print("  the indices are identical in both cases, which is why a count is kept.")
munotes.in208

Practical 16: Queues and Circular Queues

a circular queue of 5 cells
  new            : [. . . . .]  front 0 rear -1 count 0
  five enqueues  : [10 20 30 40 50]  front 0 rear 4 count 5 full? True
  three dequeues : [. . . 40 50]  front 3 rear 4 count 2
  three more in   : [60 70 80 40 50]  front 3 rear 2 count 5
  and the 60 sits at index 0, because (4 + 1) % 5 is 0

  now it really is full:
    enqueue(90): IndexError: the queue is full (all cells hold an item)

  emptying it, in order:
    dequeue -> 40  [60 70 80 . 50]  front 4 rear 2 count 4
    dequeue -> 50  [60 70 80 . .]  front 0 rear 2 count 3
    dequeue -> 60  [. 70 80 . .]  front 1 rear 2 count 2
    dequeue -> 70  [. . 80 . .]  front 2 rear 2 count 1
    dequeue -> 80  [. . . . .]  front 3 rear 2 count 0
    one dequeue too many: IndexError: the queue is empty

the full-against-empty ambiguity, if no count were kept
  empty       : front 0 rear -1 -> (rear + 1) % size is 0 which equals front? True
  and now full: front 0 rear 2 -> (rear + 1) % size is 0 which equals front? True
  the indices are identical in both cases, which is why a count is kept.
munotes.in209

Practical 16: Queues and Circular Queues

Following the wrap

The run is worth reading index by index, because this is what an examiner asks to be traced.

AfterThe arrayfrontrearcount
new. . . . .0-10
five enqueues10 20 30 40 50045
three dequeues. . . 40 50342
three enqueues60 70 80 40 50325

Look at the fourth row. The rear is at index 2 and the front is at index 3, so the rear is behind the front in the array and the queue is still in order: the items come out 40, 50, 60, 70, 80, exactly as they went in. The run proves it by emptying the queue and printing each one.

(4 + 1) % 5 is 0, which is why 60 landed at index 0 with 40 and 50 still in cells 3 and 4. That single expression is MU's "wrap-around logic".

Telling full from empty, which is the real subtlety

When front and rear meet, is the queue empty or full? The last part of the run answers it: with three cells, (rear + 1) % size == front is True when the queue is empty and True again when it is full. The indices cannot tell them apart.

Three standard cures, and MU's syllabus allows any of them:

CureHowCost
Keep a countis_full is count == size; is_empty is count == 0one integer, and every operation must maintain it
Leave one cell emptyis_full is (rear + 1) % size == frontone wasted cell, and no count to maintain
Keep a flaga boolean set when the queue becomes fulleasy to get out of step

This chapter keeps a count, which is the clearest and is what len() wants anyway. The second cure is the one to recognise in an examination question, because a question that says "a circular queue of size n can hold n - 1 items" is describing it, and the answer to "why" is exactly this ambiguity.

munotes.in210

Practical 16: Queues and Circular Queues

MU's application: task scheduling

MU asks for the queues to simulate task scheduling, and [Practical 8: CPU Scheduling, Round Robin] in Module 1 already built the scheduler. This is the same ready queue from the other side.

from collections import deque


class CircularQueue:
    def __init__(self, size):
        self.size = size
        self.items = [None] * size
        self.front = 0
        self.rear = -1
        self.count = 0

    def is_empty(self):
        return self.count == 0

    def is_full(self):
        return self.count == self.size

    def enqueue(self, item):
        if self.is_full():
            raise IndexError("the queue is full")
        self.rear = (self.rear + 1) % self.size
        self.items[self.rear] = item
        self.count += 1

    def dequeue(self):
        if self.is_empty():
            raise IndexError("the queue is empty")
        item = self.items[self.front]
        self.items[self.front] = None
        self.front = (self.front + 1) % self.size
        self.count -= 1
        return item


class Task:
    def __init__(self, name, work):
        self.name = name
        self.left = work

    def __repr__(self):
        return f"{self.name}({self.left})"


def round_robin(tasks, quantum, cells):
    """The ready queue of Practical 8, as a circular queue of fixed size."""
    q = CircularQueue(cells)
    for t in tasks:
        q.enqueue(t)

    clock, order, switches = 0, [], 0
    while not q.is_empty():
        t = q.dequeue()
        ran = min(quantum, t.left)
        clock += ran
        t.left -= ran
        order.append(f"{t.name}:{ran}")
        if t.left > 0:
            q.enqueue(t)                  # back to the rear, in the SAME cells
            switches += 1
        elif not q.is_empty():
            switches += 1
    return clock, order, switches


tasks = [Task("backup", 5), Task("print", 2), Task("index", 4)]
clock, order, switches = round_robin(tasks, 2, cells=3)
print("a task scheduler on a circular queue of 3 cells, quantum 2")
print("  the order of the slices:", " -> ".join(order))
print(f"  finished at time {clock} with {switches} context switches")
print("  the queue never needed more than 3 cells, however many rounds it took")

print()
print("MU's bullet about memory: what the two queues need for the same work")
print(f"  {'':<22}{'cells':>7}{'items ever held':>18}")
print(f"  {'circular queue':<22}{3:>7}{'3 at a time':>18}")
print(f"  {'linear queue':<22}{'grows':>7}{'3 at a time':>18}")
print("  A linear queue in a fixed array of 3 cells could not do this run at")
print("  all: the first time a task went back to the rear, the rear would")
print("  already be at the end and the enqueue would fail.")

print()
print("a deque: both ends, and what Python provides ready-made")
d = deque([20, 30])
d.append(40)          # at the rear
d.appendleft(10)      # at the FRONT, which a queue cannot do
print("  after append and appendleft :", list(d))
print("  popleft (the queue's dequeue):", d.popleft(), "->", list(d))
print("  pop     (the stack's pop)   :", d.pop(), "->", list(d))
d2 = deque([1, 2, 3], maxlen=3)
d2.append(4)
print("  a deque with maxlen=3, after appending a fourth:", list(d2))
print("  the oldest item was dropped, which is a fixed-size ring in one line")
munotes.in211

Practical 16: Queues and Circular Queues

a task scheduler on a circular queue of 3 cells, quantum 2
  the order of the slices: backup:2 -> print:2 -> index:2 -> backup:2 -> index:2 -> backup:1
  finished at time 11 with 5 context switches
  the queue never needed more than 3 cells, however many rounds it took

MU's bullet about memory: what the two queues need for the same work
                          cells   items ever held
  circular queue              3       3 at a time
  linear queue            grows       3 at a time
  A linear queue in a fixed array of 3 cells could not do this run at
  all: the first time a task went back to the rear, the rear would
  already be at the end and the enqueue would fail.

a deque: both ends, and what Python provides ready-made
  after append and appendleft : [10, 20, 30, 40]
  popleft (the queue's dequeue): 10 -> [20, 30, 40]
  pop     (the stack's pop)   : 40 -> [20, 30]
  a deque with maxlen=3, after appending a fourth: [2, 3, 4]
  the oldest item was dropped, which is a fixed-size ring in one line

What the run shows

Three cells were enough for the whole run, although the three tasks went round the queue six times between them. Every time a task was preempted it went back to the rear, into a cell that had just been vacated at the front, and the modulo found it.

A linear queue of three cells could not have done it at all. The first preemption would have found the rear already at the end and the enqueue would have failed, which is the defect of the first program appearing in a real application. That is MU's memory bullet answered:

A circular queue needs as many cells as the most items held at once. A linear queue needs as

many cells as the total number of enqueues ever made, because the rear never goes back.

For this run: three cells against eleven.

The slices and the switches agree with Module 1. Total work is 5 plus 2 plus 4, which is 11, and the clock ends at 11 because the processor is never idle. Five context switches for six slices, which is the "slices minus one" rule from [Practical 8: CPU Scheduling, Round Robin].

The deque, which is both at once

A deque, short for double-ended queue and pronounced "deck", allows adding and removing at both ends. It is therefore a stack and a queue at the same time, and Python provides one.

OperationdequeWhich structure it is
append(x)at the rearqueue's enqueue, stack's push
popleft()from the frontqueue's dequeue
pop()from the rearstack's pop
appendleft(x)at the frontneither: only a deque can
munotes.in212

Practical 16: Queues and Circular Queues

collections.deque is implemented as a doubly linked list of small blocks, so all four are O(1), and it is the right answer in real Python code for anything queue-shaped. maxlen makes it a fixed-size ring in one argument, dropping the oldest item when a new one arrives, which is exactly what a circular buffer is used for: the last hundred log lines, the last thirty video frames.

And it is not the answer to this exercise, which asks for the structure to be built.

Where circular queues are used

MU's third bullet asks for a discussion, and the honest answer is that this structure is everywhere in systems programming, under the name ring buffer or circular buffer:

  • A keyboard buffer. Keys arrive faster than a program reads them; the ring holds them.
  • A network card's receive buffer. Packets arrive; the driver takes them out.
  • The bounded buffer of [Practical 5: Process Synchronisation and the Bounded Buffer], which

is this structure with two semaphores round it.

  • A pipe, which is a fixed-size ring in the kernel, as

[Practical 2: Process Communication with Pipes] measured at 64 kibibytes.

  • The ready queue of a round robin scheduler, as above.
  • Audio and video playback, where a fixed number of buffers is filled and drained for ever.

The pattern in all six is the same: a fixed amount of memory, one producer, one consumer, and no allocation at all once it is set up. That last property is why the kernel uses it: allocating memory while handling an interrupt is not allowed, so the buffer has to exist already.

The cost of each operation

OperationLinear queue in an arrayCircular queuePython list with pop(0)deque
enqueueO(1) until the rear fillsO(1)O(1)O(1)
dequeueO(1)O(1)O(n)O(1)
peekO(1)O(1)O(1)O(1)
Cells neededtotal enqueues evermost held at oncegrowsgrows
Can it fill upyes, with free spaceyes, genuinelynoonly with maxlen

Procedure

  1. Write the linear queue and reproduce the failure: five enqueues, three dequeues, then one more

enqueue.

  1. Write the circular queue. Trace the front, the rear and the array after each of the eight

operations, on paper first.

  1. Empty it and check that the items come out in the order they went in, even though the rear is

behind the front in the array.

  1. Remove the count and implement is_full as (rear + 1) % size == front instead. The queue

now holds four items in five cells, and that is correct: say why in the journal.

  1. Write the scheduler. Change the quantum to 1 and to 5 and note how the number of switches
munotes.in213

Practical 16: Queues and Circular Queues

changes, exactly as in Practical 8.

  1. Try the scheduler with cells=2 and three tasks, and read the error.
  2. Write is_palindrome(word) using a queue and a stack together: push and enqueue every

character, then pop and dequeue and compare. It is the standard question on this pair of structures.

Result

A linear queue in a fixed array of five cells was built and shown to refuse an item while three of its five cells were free, because the rear can only move forward. A circular queue of the same size was built with the two indices advanced modulo the size, and the array was printed after every operation to show the rear wrapping past the front while the items still left in the order they arrived. The full-against-empty ambiguity was demonstrated, with (rear + 1) % size == front true for both an empty and a full queue of three cells, and resolved by keeping a count. The circular queue was then used as the ready queue of a round robin scheduler, completing eleven units of work in three cells where a linear queue would have needed eleven.

Where marks are lost

  • rear = rear + 1 without the modulo. The queue then overflows with free space, which is the

whole defect the exercise is about.

  • Only one modulo. Both front and rear wrap.
  • No way to tell full from empty. Keep a count, or leave one cell empty, and say which you

chose.

  • is_full written as rear == size - 1 in the circular version, which is the linear queue's

test and is wrong here.

  • Starting rear at 0 instead of -1, which makes the first enqueue write into cell 1 and

leaves cell 0 permanently unused, or makes an empty queue look as if it holds one item.

  • Using a Python list with pop(0) and calling it a queue implementation. Correct, O(n), and

not the exercise.

  • dequeue on an empty queue returning None instead of raising.
  • Not printing the array, so the wrap cannot be seen and the examiner cannot mark the working.
  • Saying a circular queue saves memory over a linked queue. It saves memory over a

linear array queue; a linked queue uses exactly what it needs and a link per item.

For the journal

Write the aim, MU's own wording, and the picture of five cells with front and rear marked. Then the linear queue, its run, and the three free cells beside the refusal, because that failure is the motivation for everything after it. Then the circular queue with the table of the array, front, rear and count after each operation, and the (4 + 1) % 5 is 0 beside the row where the wrap happens. Then the ambiguity, with both index comparisons coming out True, and which cure you chose. Then the scheduler and the three-cells-against-eleven comparison. The conclusion: a linear array queue needs a cell for every item it ever holds, a circular queue needs a cell only for the items it holds at once, and the difference is one modulo operator on each of the two indices.

munotes.in214

Practical 16: Queues and Circular Queues

Quick revision

  • A queue is FIFO. Enqueue at the rear, dequeue from the front, and nobody is overtaken.
  • A linear queue in a fixed array overflows while cells are free, because the rear only moves

forward. That is the defect.

  • A circular queue advances both indices modulo the size, so the array becomes a ring. Two lines,

and the problem is gone.

  • When front and rear meet, empty and full look the same. Keep a count, or leave one cell

permanently empty so that a queue of size n holds n - 1 items.

  • rear starts at -1 and front at 0, so the first enqueue writes to cell 0.
  • Memory: a circular queue needs as many cells as the most items held at once; a linear array

queue needs as many as the total enqueues ever made. In the scheduler here, three against eleven.

  • A deque allows both ends. collections.deque gives append, appendleft, pop and popleft all

in O(1), and maxlen makes a fixed-size ring in one argument.

  • A Python list with pop(0) is a queue and is O(n) per dequeue.
  • A circular queue is also called a ring buffer, and it is what a keyboard buffer, a network card,

a pipe and a bounded buffer are made of, because it allocates nothing once it exists.

  • The stack and the queue are the two opposite disciplines: LIFO against FIFO, one end against

two.

Questions you should be able to answer

1. What is the defect of a linear queue in a fixed array? The rear index only moves forward, so once it reaches the last cell the queue reports itself full even though the cells behind the front are free. On the page, three of five cells were free and an enqueue failed.

2. What is the one change that makes it a circular queue? Advancing both indices modulo the size: rear = (rear + 1) % size and the same for front. The array is then reused as a ring.

3. In a circular queue of five cells with the rear at index 4, where does the next item go? Index 0, because (4 + 1) % 5 is 0.

4. Can the rear be at a lower index than the front, and is the queue then out of order? Yes it can, and no it is not. In the run on this page the rear was at 2 and the front at 3, and the items still came out in the order they arrived.

munotes.in215

Practical 16: Queues and Circular Queues

5. When front and rear meet, is the queue empty or full, and how is it decided? It cannot be decided from the indices alone: (rear + 1) % size == front is true in both cases. Either keep a count of the items, or leave one cell permanently empty so that a queue of n cells holds n - 1 items.

6. How many cells does a circular queue need, and how many does a linear one? The circular queue needs as many as the greatest number of items held at once. The linear array queue needs as many as the total number of enqueues that will ever be made, because the rear never returns.

7. What is a deque, and what can it do that a queue cannot? A double-ended queue. It can add and remove at both ends, so it is a stack and a queue at once; a queue can only add at the rear and remove at the front.

8. Give three places a circular queue is used in a real system. A keyboard buffer, a network card's receive ring, and the bounded buffer between a producer and a consumer. A pipe in the kernel is one too. The common reason is that it allocates no memory once it exists.

9. Why is a Python list with pop(0) a poor queue? Because removing the first item shifts every other item down one, so each dequeue is O(n). Measured over 20000 operations, that kind of front operation was about ninety-nine times slower than the cheap one.

Contents This chapter on its own page

munotes.in216

Chapter Twenty-Five

Practical 17: Binary Search Trees and Tree Traversals

Syllabus topic Module 2, "Tree Traversals and Binary Search Trees: Create a binary search tree (BST) from a dataset. Perform and visualize in-order, pre-order, and post-order traversals. Use traversal results to derive sorted sequences."

Aim

To build a binary search tree from a dataset, to search it, to perform the in-order, pre-order, post-order and level-order traversals, to delete a node in each of the three cases, and to see what the insertion order does to the height.

What you need to know before you start

A binary tree is a node with up to two children, a left and a right, and every child is itself a binary tree. One node is the root; a node with no children is a leaf.

WordMeaning
Rootthe one node with no parent
Leafa node with no children
Parent, childthe obvious relation, one level apart
Subtreea node and everything below it
Depth of a nodethe number of edges from the root down to it; the root's depth is 0
Height of the treethe depth of the deepest node, so the longest path down
Levelall the nodes at one depth
Full binary treeevery node has 0 or 2 children
Complete binary treeevery level full except perhaps the last, filled from the left

Height is counted in edges here, so a single node has height 0 and an empty tree height -1. Some books count nodes instead, and then the same tree has height 1. Say which you are using; both are accepted and mixing them is not.

The binary search tree property

A binary search tree is a binary tree with one rule, and it holds at every node:

every key in the left subtree is smaller than the node's key, and every key in the right subtree

is larger.

That one rule is what makes the tree useful. To find a key you compare it with the root: smaller, go left; larger, go right; equal, found. Each comparison throws away a whole subtree, so a search costs one comparison per level, not one per node.

Unsorted arraySorted arrayBST, balancedBST, degenerate
SearchO(n)O(log n) by bisectionO(log n)O(n)
InsertO(1) at the endO(n), everything shiftsO(log n)O(n)
DeleteO(n)O(n)O(log n)O(n)
In sorted ordersort it, O(n log n)it already isin-order walk, O(n)the same

The sorted array is as fast to search and slow to change; the tree is fast at both, provided it stays balanced, and the last column is what happens when it does not. That is the whole of the last section of this chapter and the reason for [Practical 18: AVL Trees and Rebalancing].

The class, the traversals and the search

class Node:
    def __init__(self, key):
        self.key = key
        self.left = None
        self.right = None

    def __repr__(self):
        return f"Node({self.key})"


class BST:
    """A binary search tree: everything left of a node is smaller,
    everything right of it is larger."""

    def __init__(self, keys=()):
        self.root = None
        self.count = 0
        for k in keys:
            self.insert(k)

    def __len__(self):
        return self.count

    # ---- putting keys in -------------------------------------------------
    def insert(self, key):
        """Iterative, so a long tree cannot exhaust the call stack."""
        node = Node(key)
        if self.root is None:
            self.root = node
            self.count += 1
            return
        here = self.root
        while True:
            if key == here.key:
                return                      # no duplicates in this tree
            if key < here.key:
                if here.left is None:
                    here.left = node
                    self.count += 1
                    return
                here = here.left
            else:
                if here.right is None:
                    here.right = node
                    self.count += 1
                    return
                here = here.right

    # ---- finding keys ----------------------------------------------------
    def search(self, key):
        """Returns (found, how many comparisons it took)."""
        here, steps = self.root, 0
        while here is not None:
            steps += 1
            if key == here.key:
                return True, steps
            here = here.left if key < here.key else here.right
        return False, steps

    def minimum(self):
        if self.root is None:
            raise IndexError("the tree is empty")
        here = self.root
        while here.left is not None:
            here = here.left
        return here.key

    def maximum(self):
        if self.root is None:
            raise IndexError("the tree is empty")
        here = self.root
        while here.right is not None:
            here = here.right
        return here.key

    def height(self):
        """The number of EDGES on the longest path down. A leaf has height 0."""
        def down(node):
            if node is None:
                return -1
            return 1 + max(down(node.left), down(node.right))
        return down(self.root)

    # ---- the four traversals ---------------------------------------------
    def in_order(self):
        out = []

        def walk(node):
            if node is None:
                return
            walk(node.left)
            out.append(node.key)
            walk(node.right)

        walk(self.root)
        return out

    def pre_order(self):
        out = []

        def walk(node):
            if node is None:
                return
            out.append(node.key)
            walk(node.left)
            walk(node.right)

        walk(self.root)
        return out

    def post_order(self):
        out = []

        def walk(node):
            if node is None:
                return
            walk(node.left)
            walk(node.right)
            out.append(node.key)

        walk(self.root)
        return out

    def level_order(self):
        """Breadth first, with a QUEUE. The only traversal that needs one."""
        if self.root is None:
            return []
        out, queue = [], [self.root]
        at = 0
        while at < len(queue):
            node = queue[at]
            at += 1
            out.append(node.key)
            if node.left:
                queue.append(node.left)
            if node.right:
                queue.append(node.right)
        return out

    def in_order_iterative(self):
        """The same answer with an explicit STACK instead of recursion."""
        out, stack, here = [], [], self.root
        while stack or here is not None:
            while here is not None:            # go as far left as possible
                stack.append(here)
                here = here.left
            here = stack.pop()                 # then take one
            out.append(here.key)
            here = here.right                  # and turn right
        return out

    # ---- drawing it ------------------------------------------------------
    def draw(self):
        """Sideways: the RIGHT subtree above, then the node, then the left.
        Read it with your head tilted left and it is the usual picture."""
        def walk(node, depth):
            if node is None:
                return
            walk(node.right, depth + 1)
            print("    " + "        " * depth + str(node.key))
            walk(node.left, depth + 1)

        if self.root is None:
            print("    (empty)")
        else:
            walk(self.root, 0)

keys = [50, 30, 70, 20, 40, 60, 80, 35]
t = BST(keys)
print("inserted in this order:", keys)
print("the tree:")
t.draw()

print()
print("nodes    :", len(t))
print("height   :", t.height(), "edges on the longest path down")
print("minimum  :", t.minimum(), " maximum:", t.maximum())

print()
print("in-order   :", t.in_order(), " <- SORTED, and that is the point")
print("pre-order  :", t.pre_order())
print("post-order :", t.post_order())
print("level-order:", t.level_order())
print("in-order again, with a stack instead of recursion:", t.in_order_iterative())
print("the two in-order walks agree:", t.in_order() == t.in_order_iterative())

print()
print("searching")
for k in (35, 60, 55, 50):
    found, steps = t.search(k)
    print(f"  {k:<4} {'found' if found else 'not found':<10} in {steps} comparison(s)")
munotes.in217

Practical 17: Binary Search Trees and Tree Traversals

inserted in this order: [50, 30, 70, 20, 40, 60, 80, 35]
the tree:
                    80
            70
                    60
    50
                    40
                            35
            30
                    20

nodes    : 8
height   : 3 edges on the longest path down
minimum  : 20  maximum: 80

in-order   : [20, 30, 35, 40, 50, 60, 70, 80]  <- SORTED, and that is the point
pre-order  : [50, 30, 20, 40, 35, 70, 60, 80]
post-order : [20, 35, 40, 30, 60, 80, 70, 50]
level-order: [50, 30, 70, 20, 40, 60, 80, 35]
in-order again, with a stack instead of recursion: [20, 30, 35, 40, 50, 60, 70, 80]
the two in-order walks agree: True

searching
  35   found      in 4 comparison(s)
  60   found      in 3 comparison(s)
  55   not found  in 3 comparison(s)
  50   found      in 1 comparison(s)
munotes.in218

Practical 17: Binary Search Trees and Tree Traversals

Reading the drawing

The tree is printed sideways: the right subtree above, then the node, then the left subtree, with one indent per level. Tilt your head to the left and it is the usual picture. The root 50 is at the left margin; 70 and 30 are its children; 80, 60, 40 and 20 are theirs; and 35 is the one node at depth 3, which is why the height is 3.

MU's bullet says "visualize", and a drawing the program produced is worth more in a journal than one drawn by hand, because it cannot disagree with the data structure.

The four traversals

A traversal visits every node once. The three depth-first ones differ only in when the node itself is visited relative to its two subtrees:

TraversalOrderResult hereWhat it is for
In-orderleft, node, right20 30 35 40 50 60 70 80the keys in sorted order
Pre-ordernode, left, right50 30 20 40 35 70 60 80copying a tree, and prefix expressions
Post-orderleft, right, node20 35 40 30 60 80 70 50deleting a tree, and postfix expressions
Level-orderlevel by level, top down50 30 70 20 40 60 80 35breadth-first search, printing by level
munotes.in219

Practical 17: Binary Search Trees and Tree Traversals

In-order gives the sorted sequence, and that is MU's third bullet. It is not a coincidence: the rule says everything left of a node is smaller, so visiting the left subtree, then the node, then the right subtree, visits the keys in increasing order. Building a tree and walking it in-order is a sorting algorithm, called tree sort, and it costs O(n log n) on a balanced tree, which is the same as any good sort.

Pre-order is how you save a tree and get the same tree back. Insert the keys in pre-order into an empty tree and the shape is reproduced exactly, because the root comes first at every level. In-order would not: inserting the sorted sequence gives the degenerate tree of the last section.

Post-order is how you delete a tree, in a language where you must free memory: both children are finished with before the node, so nothing is freed while it is still needed.

Level-order is the odd one out, and the program shows why: it is the only traversal that needs a queue rather than recursion. The other three are naturally recursive; this one takes nodes from the front of a queue and adds their children to the rear. That is [Practical 16: Queues and Circular Queues] and it is exactly the breadth-first search of [Practical 19: Graph Representations and Traversals].

Recursion against an explicit stack

in_order is recursive and in_order_iterative is the same traversal with a stack that the program manages, and the run proves they agree. The iterative form is worth knowing for two reasons:

  • Recursion uses the call stack, which is finite. A degenerate tree of a hundred thousand

nodes overflows it and Python raises RecursionError; the explicit stack is on the heap and does not.

  • The pattern is the same as the expression evaluation of

[Practical 15 continued: Prefix to Postfix, and Evaluating It]: go as far left as you can, pushing as you go, then take one and turn right. Recognising that a recursion is a stack is worth a mark.

Search, and what the comparison count tells you

The run searches for four keys and prints the comparisons each took:

KeyComparisonsPath
501it is the root
60350, 70, 60
35450, 30, 40, 35
55350, 70, 60, then nowhere left to go

A search that fails costs the same as one that succeeds at that depth, because it walks down until it falls off the tree. And the deepest key costs one comparison per level, which for a balanced tree of n keys is about log2 n. Printing the count is how you show that, and it is what makes the comparison with the degenerate tree measurable.

munotes.in220

Practical 17: Binary Search Trees and Tree Traversals

Deleting a node: the three cases

Deletion is the one operation in this exercise with real cases to it, and an examiner asks for all three by name.

class Node:
    def __init__(self, key):
        self.key = key
        self.left = None
        self.right = None

    def __repr__(self):
        return f"Node({self.key})"


class BST:
    """A binary search tree: everything left of a node is smaller,
    everything right of it is larger."""

    def __init__(self, keys=()):
        self.root = None
        self.count = 0
        for k in keys:
            self.insert(k)

    def __len__(self):
        return self.count

    # ---- putting keys in -------------------------------------------------
    def insert(self, key):
        """Iterative, so a long tree cannot exhaust the call stack."""
        node = Node(key)
        if self.root is None:
            self.root = node
            self.count += 1
            return
        here = self.root
        while True:
            if key == here.key:
                return                      # no duplicates in this tree
            if key < here.key:
                if here.left is None:
                    here.left = node
                    self.count += 1
                    return
                here = here.left
            else:
                if here.right is None:
                    here.right = node
                    self.count += 1
                    return
                here = here.right

    # ---- finding keys ----------------------------------------------------
    def search(self, key):
        """Returns (found, how many comparisons it took)."""
        here, steps = self.root, 0
        while here is not None:
            steps += 1
            if key == here.key:
                return True, steps
            here = here.left if key < here.key else here.right
        return False, steps

    def minimum(self):
        if self.root is None:
            raise IndexError("the tree is empty")
        here = self.root
        while here.left is not None:
            here = here.left
        return here.key

    def maximum(self):
        if self.root is None:
            raise IndexError("the tree is empty")
        here = self.root
        while here.right is not None:
            here = here.right
        return here.key

    def height(self):
        """The number of EDGES on the longest path down. A leaf has height 0."""
        def down(node):
            if node is None:
                return -1
            return 1 + max(down(node.left), down(node.right))
        return down(self.root)

    # ---- the four traversals ---------------------------------------------
    def in_order(self):
        out = []

        def walk(node):
            if node is None:
                return
            walk(node.left)
            out.append(node.key)
            walk(node.right)

        walk(self.root)
        return out

    def pre_order(self):
        out = []

        def walk(node):
            if node is None:
                return
            out.append(node.key)
            walk(node.left)
            walk(node.right)

        walk(self.root)
        return out

    def post_order(self):
        out = []

        def walk(node):
            if node is None:
                return
            walk(node.left)
            walk(node.right)
            out.append(node.key)

        walk(self.root)
        return out

    def level_order(self):
        """Breadth first, with a QUEUE. The only traversal that needs one."""
        if self.root is None:
            return []
        out, queue = [], [self.root]
        at = 0
        while at < len(queue):
            node = queue[at]
            at += 1
            out.append(node.key)
            if node.left:
                queue.append(node.left)
            if node.right:
                queue.append(node.right)
        return out

    def in_order_iterative(self):
        """The same answer with an explicit STACK instead of recursion."""
        out, stack, here = [], [], self.root
        while stack or here is not None:
            while here is not None:            # go as far left as possible
                stack.append(here)
                here = here.left
            here = stack.pop()                 # then take one
            out.append(here.key)
            here = here.right                  # and turn right
        return out

    # ---- drawing it ------------------------------------------------------
    def draw(self):
        """Sideways: the RIGHT subtree above, then the node, then the left.
        Read it with your head tilted left and it is the usual picture."""
        def walk(node, depth):
            if node is None:
                return
            walk(node.right, depth + 1)
            print("    " + "        " * depth + str(node.key))
            walk(node.left, depth + 1)

        if self.root is None:
            print("    (empty)")
        else:
            walk(self.root, 0)

    # ---- taking keys out: the three cases --------------------------------
    def delete(self, key):
        self.root, removed = self._delete(self.root, key)
        if not removed:
            raise KeyError(f"{key} is not in the tree")
        self.count -= 1

    def _delete(self, node, key):
        """Returns (the subtree after the deletion, was anything removed)."""
        if node is None:
            return None, False
        if key < node.key:
            node.left, removed = self._delete(node.left, key)
            return node, removed
        if key > node.key:
            node.right, removed = self._delete(node.right, key)
            return node, removed

        # this is the node to remove
        if node.left is None and node.right is None:
            return None, True                       # case 1: a leaf
        if node.left is None:
            return node.right, True                 # case 2: one child
        if node.right is None:
            return node.left, True                  # case 2 again
        # case 3: two children. Take the smallest key on the RIGHT, which is
        # the in-order SUCCESSOR, put it here, and delete it from the right.
        here = node.right
        while here.left is not None:
            here = here.left
        node.key = here.key
        node.right, _ = self._delete(node.right, here.key)
        return node, True


keys = [50, 30, 70, 20, 40, 60, 80, 35]
t = BST(keys)
print("the tree we start from, in-order:", t.in_order())
t.draw()

print()
print("case 1, a LEAF: delete 20")
t.delete(20)
t.draw()
print("  in-order:", t.in_order(), " still sorted:",
      t.in_order() == sorted(t.in_order()))

print()
print("case 2, ONE child: delete 40, whose only child is 35")
t.delete(40)
t.draw()
print("  in-order:", t.in_order(), " still sorted:",
      t.in_order() == sorted(t.in_order()))

print()
print("case 3, TWO children: delete 70, children 60 and 80")
t.delete(70)
t.draw()
print("  in-order:", t.in_order(), " still sorted:",
      t.in_order() == sorted(t.in_order()))
print("  80 took its place: it is the smallest key to the right of 70")

print()
print("case 3 at the ROOT: delete 50")
t.delete(50)
t.draw()
print("  in-order:", t.in_order(), " still sorted:",
      t.in_order() == sorted(t.in_order()))

print()
print("nodes left:", len(t))
try:
    t.delete(99)
except KeyError as e:
    print("deleting a key that is not there: KeyError:", e)
munotes.in221

Practical 17: Binary Search Trees and Tree Traversals

the tree we start from, in-order: [20, 30, 35, 40, 50, 60, 70, 80]
                    80
            70
                    60
    50
                    40
                            35
            30
                    20

case 1, a LEAF: delete 20
                    80
            70
                    60
    50
                    40
                            35
            30
  in-order: [30, 35, 40, 50, 60, 70, 80]  still sorted: True

case 2, ONE child: delete 40, whose only child is 35
                    80
            70
                    60
    50
                    35
            30
  in-order: [30, 35, 50, 60, 70, 80]  still sorted: True

case 3, TWO children: delete 70, children 60 and 80
            80
                    60
    50
                    35
            30
  in-order: [30, 35, 50, 60, 80]  still sorted: True
  80 took its place: it is the smallest key to the right of 70

case 3 at the ROOT: delete 50
            80
    60
                    35
            30
  in-order: [30, 35, 60, 80]  still sorted: True

nodes left: 4
deleting a key that is not there: KeyError: '99 is not in the tree'
munotes.in222

Practical 17: Binary Search Trees and Tree Traversals

The three cases

CaseThe node hasWhat to do
1no childrenremove it: its parent's link becomes None
2one childlift the child into its place
3two childrenreplace its key with its in-order successor, then delete that successor from the right subtree

Case 3 is the only one that needs thinking about, and the reason it works is worth stating. The in-order successor is the smallest key in the right subtree, so it is larger than everything on the left and smaller than everything else on the right. Putting it in the deleted node's place therefore keeps the search property at that node, and it is the only key that does, apart from the in-order predecessor, the largest key on the left, which works equally well by symmetry.

And the successor is easy to find and easy to remove: walk left from the right child until you cannot. Such a node has no left child by construction, so deleting it is case 1 or case 2, never case 3. The recursion cannot go round more than once.

The invariant is checked after every deletion. t.in_order() == sorted(t.in_order()) is printed four times and is True four times. That is the whole test for a binary search tree: if the in-order walk is sorted, the search property holds everywhere, and if a deletion broke it the check would catch it at once. It is the cheapest possible test and it belongs in the journal.

Deleting the root is not a fourth case. The run does it, and it goes through case 3 like any other node; the only difference is that self.root is reassigned, which the self.root, removed = line at the top of delete handles.

What the insertion order does to the tree

MU asks for a tree to be built from a dataset, and this is the question that decides whether it was worth building.

class Node:
    def __init__(self, key):
        self.key = key
        self.left = None
        self.right = None

    def __repr__(self):
        return f"Node({self.key})"


class BST:
    """A binary search tree: everything left of a node is smaller,
    everything right of it is larger."""

    def __init__(self, keys=()):
        self.root = None
        self.count = 0
        for k in keys:
            self.insert(k)

    def __len__(self):
        return self.count

    # ---- putting keys in -------------------------------------------------
    def insert(self, key):
        """Iterative, so a long tree cannot exhaust the call stack."""
        node = Node(key)
        if self.root is None:
            self.root = node
            self.count += 1
            return
        here = self.root
        while True:
            if key == here.key:
                return                      # no duplicates in this tree
            if key < here.key:
                if here.left is None:
                    here.left = node
                    self.count += 1
                    return
                here = here.left
            else:
                if here.right is None:
                    here.right = node
                    self.count += 1
                    return
                here = here.right

    # ---- finding keys ----------------------------------------------------
    def search(self, key):
        """Returns (found, how many comparisons it took)."""
        here, steps = self.root, 0
        while here is not None:
            steps += 1
            if key == here.key:
                return True, steps
            here = here.left if key < here.key else here.right
        return False, steps

    def minimum(self):
        if self.root is None:
            raise IndexError("the tree is empty")
        here = self.root
        while here.left is not None:
            here = here.left
        return here.key

    def maximum(self):
        if self.root is None:
            raise IndexError("the tree is empty")
        here = self.root
        while here.right is not None:
            here = here.right
        return here.key

    def height(self):
        """The number of EDGES on the longest path down. A leaf has height 0."""
        def down(node):
            if node is None:
                return -1
            return 1 + max(down(node.left), down(node.right))
        return down(self.root)

    # ---- the four traversals ---------------------------------------------
    def in_order(self):
        out = []

        def walk(node):
            if node is None:
                return
            walk(node.left)
            out.append(node.key)
            walk(node.right)

        walk(self.root)
        return out

    def pre_order(self):
        out = []

        def walk(node):
            if node is None:
                return
            out.append(node.key)
            walk(node.left)
            walk(node.right)

        walk(self.root)
        return out

    def post_order(self):
        out = []

        def walk(node):
            if node is None:
                return
            walk(node.left)
            walk(node.right)
            out.append(node.key)

        walk(self.root)
        return out

    def level_order(self):
        """Breadth first, with a QUEUE. The only traversal that needs one."""
        if self.root is None:
            return []
        out, queue = [], [self.root]
        at = 0
        while at < len(queue):
            node = queue[at]
            at += 1
            out.append(node.key)
            if node.left:
                queue.append(node.left)
            if node.right:
                queue.append(node.right)
        return out

    def in_order_iterative(self):
        """The same answer with an explicit STACK instead of recursion."""
        out, stack, here = [], [], self.root
        while stack or here is not None:
            while here is not None:            # go as far left as possible
                stack.append(here)
                here = here.left
            here = stack.pop()                 # then take one
            out.append(here.key)
            here = here.right                  # and turn right
        return out

    # ---- drawing it ------------------------------------------------------
    def draw(self):
        """Sideways: the RIGHT subtree above, then the node, then the left.
        Read it with your head tilted left and it is the usual picture."""
        def walk(node, depth):
            if node is None:
                return
            walk(node.right, depth + 1)
            print("    " + "        " * depth + str(node.key))
            walk(node.left, depth + 1)

        if self.root is None:
            print("    (empty)")
        else:
            walk(self.root, 0)

import math

print("the SAME eight keys, inserted in two different orders")
print()

balanced = BST([50, 30, 70, 20, 40, 60, 80, 35])
print("inserted 50 30 70 20 40 60 80 35")
print("  height", balanced.height(), " in-order", balanced.in_order())

degenerate = BST([20, 30, 35, 40, 50, 60, 70, 80])
print("inserted in SORTED order 20 30 35 40 50 60 70 80")
print("  height", degenerate.height(), " in-order", degenerate.in_order())
degenerate.draw()
print("  every node has one child: this is a linked list wearing a tree's name")

print()
print("what that costs, in comparisons to find a key")
print(f"  {'key':<6}{'balanced':>10}{'degenerate':>12}")
for k in (20, 40, 80):
    _, a = balanced.search(k)
    _, b = degenerate.search(k)
    print(f"  {k:<6}{a:>10}{b:>12}")

print()
print("and at a size where it matters. Height is counted in EDGES, so a")
print("perfectly balanced tree of n keys has height log2(n + 1) - 1.")
for n in (15, 1023, 1048575):
    best = int(math.log2(n + 1)) - 1
    print(f"  {n:>8} keys: balanced height {best:>3}, "
          f"degenerate height {n - 1:>8}")

print()
print("the same measured, by building both and asking")
for n in (7, 15, 31, 63):
    sorted_tree = BST(range(n))
    middle_first = BST([])
    def add_middles(lo, hi):
        if lo > hi:
            return
        mid = (lo + hi) // 2
        middle_first.insert(mid)
        add_middles(lo, mid - 1)
        add_middles(mid + 1, hi)
    add_middles(0, n - 1)
    print(f"  {n:>3} keys: sorted order gives height {sorted_tree.height():>3}, "
          f"middle-first gives height {middle_first.height():>3}")
munotes.in223

Practical 17: Binary Search Trees and Tree Traversals

the SAME eight keys, inserted in two different orders

inserted 50 30 70 20 40 60 80 35
  height 3  in-order [20, 30, 35, 40, 50, 60, 70, 80]
inserted in SORTED order 20 30 35 40 50 60 70 80
  height 7  in-order [20, 30, 35, 40, 50, 60, 70, 80]
                                                            80
                                                    70
                                            60
                                    50
                            40
                    35
            30
    20
  every node has one child: this is a linked list wearing a tree's name

what that costs, in comparisons to find a key
  key     balanced  degenerate
  20             3           1
  40             3           4
  80             3           8

and at a size where it matters. Height is counted in EDGES, so a
perfectly balanced tree of n keys has height log2(n + 1) - 1.
        15 keys: balanced height   3, degenerate height       14
      1023 keys: balanced height   9, degenerate height     1022
   1048575 keys: balanced height  19, degenerate height  1048574

the same measured, by building both and asking
    7 keys: sorted order gives height   6, middle-first gives height   2
   15 keys: sorted order gives height  14, middle-first gives height   3
   31 keys: sorted order gives height  30, middle-first gives height   4
   63 keys: sorted order gives height  62, middle-first gives height   5
munotes.in224

Practical 17: Binary Search Trees and Tree Traversals

The same keys, two trees

Eight keys inserted in one order give a tree of height 3. The same eight keys inserted in sorted order give height 7, and the drawing shows what that means: every node has exactly one child, and the tree is a linked list.

munotes.in225

Practical 17: Binary Search Trees and Tree Traversals

The comparison counts say what it costs. In the balanced tree every one of those searches took 3 comparisons. In the degenerate tree, 80, the last key, took 8. And the in-order walk of both is the same sorted sequence, so the traversal cannot tell you the tree is broken. Only the height can.

At a size where it matters

KeysBalanced heightDegenerate height
15314
102391022
1048575191048574

A million keys: twenty comparisons or a million. That is the difference between a database that answers instantly and one that does not answer.

The last block of the run measures it rather than asserting it: trees are built two ways and asked their height. Sorted insertion of 63 keys gives height 62; inserting the middle key first and then recursing on the halves gives height 5. Both hold the same 63 keys.

Why sorted input is the common case, and that is the problem

The degenerate tree is not a freak. Data arrives sorted all the time: roll numbers in order, dates in order, records read from a sorted file. The commonest real input is the worst case for a plain binary search tree, which is precisely why self-balancing trees exist and why the next chapter is about one.

Three answers to it, and the first two are stopgaps:

  1. Shuffle the input before inserting. Works, and you must have all the data first.
  2. Insert the middle key first and recurse on the halves, as the run does. Works, and needs

the data sorted and complete in advance.

  1. Rebalance as you insert, which is the AVL tree of [Practical 18: AVL Trees and Rebalancing]

and the red-black tree that Java's TreeMap and C++'s std::map use. This is the real answer.

The cost of each operation

h is the height of the tree, n the number of keys.

OperationCostBalancedDegenerate
SearchO(h)O(log n)O(n)
InsertO(h)O(log n)O(n)
DeleteO(h)O(log n)O(n)
Minimum, maximumO(h)O(log n)O(n)
HeightO(n)O(n)O(n)
Any traversalO(n)O(n)O(n)
SpaceO(n)O(n)O(n)

Every operation except the traversals is O(h), and the whole art of the subject is keeping h near log n.

Procedure

  1. Write the class and run it. Check the four traversals against the drawing by hand.
  2. Insert 50 into the tree again and confirm nothing changes, because this tree holds no

duplicates. Decide what your college wants for duplicates and say so in the journal: refuse them, count them, or always go right.

munotes.in226

Practical 17: Binary Search Trees and Tree Traversals

  1. Write the deletion program and follow all four deletions in the drawings.
  2. Delete 30 from the original tree, which has two children, and confirm 35 takes its place.
  3. Change case 3 to use the in-order predecessor, the largest key on the left, and confirm the

in-order walk is still sorted.

  1. Write the third program. Insert 1 to 20 in order and print the height.
  2. Add a to_sorted_list() that is just in_order, and a from_sorted_list() that inserts the

middle first. Together they rebalance a tree, in O(n), and that is a real technique.

Result

A binary search tree was built from eight keys, drawn by the program, and searched with the comparison count printed. All four traversals were produced, and the in-order walk was the keys in sorted order, which is the search property expressed as a sequence. The in-order walk was produced twice, recursively and with an explicit stack, and the two agreed. Deletion was implemented in all three cases and exercised on a leaf, on a node with one child, on a node with two children and on the root, and after each the in-order walk was checked to be still sorted. The same eight keys inserted in sorted order gave a tree of height 7 against 3, and at 63 keys the measured heights were 62 against 5.

Where marks are lost

  • Mixing up the traversals. In-order is left, node, right. The name says where the node

goes.

  • Not saying that in-order gives the sorted order, which is MU's own third bullet.
  • Getting case 3 of deletion wrong: replacing with any node other than the in-order successor

or predecessor breaks the search property.

  • Not handling deletion of the root.
  • Not checking that the in-order walk is still sorted after a deletion, which is the only test

that matters.

  • Counting height in nodes in one place and edges in another. Pick one and say which.
  • Saying a BST search is O(log n) without the word "balanced". It is O(h), and h can be n.
  • No drawing. MU's bullet says visualize.
  • Recursive traversals on a very deep tree, which raises RecursionError where an explicit

stack would not.

For the journal

Write the aim, MU's own wording, and the definitions of root, leaf, depth, height and the search property, saying whether you count height in edges or nodes. Then the dataset, the program, the drawing, and the four traversals with the in-order one marked as the sorted sequence. Then the four deletions with the drawing after each and the "still sorted: True" beside each, because that check is the proof. Then the two insertion orders with their heights and the comparison counts, and the table of balanced against degenerate heights at 15, 1023 and 1048575 keys. The conclusion: the search property makes every operation cost one comparison per level, so the tree is only as good as its height, and the height depends entirely on the order the keys arrived in.

munotes.in227

Practical 17: Binary Search Trees and Tree Traversals

Quick revision

  • A BST holds, at every node: everything left is smaller, everything right is larger.
  • Height is counted in edges here: a leaf has height 0, an empty tree -1. Say which convention you

use.

  • Search, insert and delete are all O(h). Balanced, h is about log2 n; degenerate, h is n - 1.
  • In-order is left, node, right, and gives the keys sorted. Pre-order is node, left, right,

and reproduces the tree if re-inserted. Post-order is left, right, node, and is how a tree is freed.

  • Level-order is breadth first and is the only traversal that needs a queue.
  • A recursive traversal can be written with an explicit stack: go left pushing, take one, turn

right. That avoids RecursionError on a deep tree.

  • Deletion has three cases: a leaf is removed; one child is lifted into place; two children are

replaced by the in-order successor, the smallest key on the right, which is then deleted from the right subtree.

  • The successor has no left child, so removing it is case 1 or 2 and the recursion cannot repeat.
  • Test a BST by checking that the in-order walk is sorted. Nothing else is needed.
  • Inserting in sorted order gives a degenerate tree: 63 keys measured at height 62 against 5.
  • Sorted input is the commonest real input, which is why self-balancing trees exist.

Questions you should be able to answer

1. State the binary search tree property. At every node, every key in the left subtree is smaller than that node's key and every key in the right subtree is larger.

2. Which traversal gives the keys in sorted order, and why? In-order, left then node then right. Everything left of a node is smaller than it and everything right is larger, so visiting in that order visits the keys in increasing order.

3. Give the three traversals by name and by order. In-order: left, node, right. Pre-order: node, left, right. Post-order: left, right, node.

4. Which traversal reproduces the tree if the keys are re-inserted, and which needs a queue? Pre-order reproduces it, because the root of each subtree comes first. Level-order needs a queue; the other three are naturally recursive.

5. Give the three cases of deletion. A leaf is simply removed. A node with one child is replaced by that child. A node with two children has its key replaced by its in-order successor, the smallest key in its right subtree, and that successor is then deleted from the right subtree.

munotes.in228

Practical 17: Binary Search Trees and Tree Traversals

6. Why is the in-order successor the right key to move up? Because it is larger than everything in the left subtree and smaller than everything else in the right, so the search property still holds at that node. The in-order predecessor works equally well.

7. How do you test that a deletion has not broken the tree? Check that the in-order traversal is still in sorted order. If it is, the search property holds at every node.

8. Eight keys inserted in sorted order. What is the height, and what does the tree look like? Height 7 for eight keys: every node has exactly one child, so the tree is a linked list and every operation is O(n).

9. Is a binary search tree search O(log n)? It is O(h), the height. For a balanced tree that is about log2 n; for a degenerate one it is n. The word "balanced" is not optional.

10. Why does sorted input matter so much in practice? Because it is the commonest kind of real input, roll numbers or dates or records from a sorted file, and it is the worst case for a plain binary search tree. That is the reason self-balancing trees exist.

Contents This chapter on its own page

munotes.in229

Chapter Twenty-Six

Practical 18: AVL Trees and Rebalancing

Syllabus topic Module 2, "Balanced Trees and Priority Queues: Insert values and observe AVL tree rebalancing."

Aim

To build an AVL tree, to observe the rebalancing as values are inserted, to implement all four rotation cases and deletion, and to compare the height with a plain binary search tree.

What you need to know before you start

[Practical 17: Binary Search Trees and Tree Traversals] ended on a problem. A binary search tree's operations cost O(h), the height, and the height depends on the order the keys arrived in. Sorted input, which is the commonest real input, gives a height of n - 1 and a structure that is a linked list: 63 keys at height 62, measured.

An AVL tree is a binary search tree that fixes this. It was published in 1962 by two Soviet mathematicians, Georgy Adelson-Velsky and Evgenii Landis, whose initials are its name, and it was the first self-balancing tree of any kind.

The balance factor

For every node,

balance factor = height of the left subtree - height of the right subtree

and the AVL rule is:

every node's balance factor must be -1, 0 or +1.

That is the whole definition. A node with a balance factor of +2 is left heavy and one with -2 is right heavy, and either is illegal and must be fixed the moment it appears.

The rule is at every node, not just the root. A tree whose root is balanced can be illegal lower down, and the checking program in this chapter walks the whole tree for exactly that reason.

The consequence of the rule is the whole point: a tree obeying it has height at most about 1.44 times log2 n, so every operation is O(log n) and stays there whatever order the keys arrive in.

The four cases, and the two rotations they are made of

When an insertion makes a node's balance factor 2 or -2, there is exactly one node to fix: the lowest unbalanced one on the path back up. There are four shapes it can have, and they are named for the two steps down towards the new key.

CaseThe node isIts heavy child isFix
LLleft heavy, bf +2left heavy or balancedone right rotation at the node
RRright heavy, bf -2right heavy or balancedone left rotation at the node
LRleft heavy, bf +2right heavyleft rotation at the child, then right at the node
RLright heavy, bf -2left heavyright rotation at the child, then left at the node

LL and RR are single rotations; LR and RL are double rotations, and a double rotation is just the two single ones done in order. So the whole of AVL rebalancing is two functions, rotate_left and rotate_right, and four ways of calling them.

munotes.in230

Practical 18: AVL Trees and Rebalancing

A rotation is three pointer assignments and two height refreshes, and it changes the shape without changing the in-order sequence. That last property is what makes it safe: after a rotation the tree is still a binary search tree holding the same keys in the same sorted order.

The four cases, each from three keys

The smallest example of each case is three keys, and the four insertion orders below produce all four.

class Node:
    def __init__(self, key):
        self.key = key
        self.left = None
        self.right = None
        self.height = 0            # a leaf has height 0, counted in EDGES

    def __repr__(self):
        return f"Node({self.key})"


def height(node):
    """-1 for an empty subtree, so a leaf's height comes out 0."""
    return -1 if node is None else node.height


def balance_factor(node):
    """left height minus right height. Legal values are -1, 0 and +1."""
    return 0 if node is None else height(node.left) - height(node.right)


def refresh(node):
    node.height = 1 + max(height(node.left), height(node.right))


class AVL:
    """A binary search tree that rebalances itself on every insertion,
    so that no node's two subtrees ever differ in height by more than 1."""

    def __init__(self, keys=(), trace=False):
        self.root = None
        self.count = 0
        self.rotations = 0
        self.trace = trace
        for k in keys:
            self.insert(k)

    def __len__(self):
        return self.count

    # ---- the two rotations, from which all four cases are built ----------
    def rotate_right(self, y):
        """y goes down to the right, its left child x comes up."""
        x = y.left
        y.left = x.right
        x.right = y
        refresh(y)
        refresh(x)
        self.rotations += 1
        return x                          # x is the new root of this subtree

    def rotate_left(self, x):
        """x goes down to the left, its right child y comes up."""
        y = x.right
        x.right = y.left
        y.left = x
        refresh(x)
        refresh(y)
        self.rotations += 1
        return y

    # ---- insertion, which is a BST insert plus one rebalancing step ------
    def insert(self, key):
        self.root = self._insert(self.root, key)

    def _insert(self, node, key):
        if node is None:
            self.count += 1
            return Node(key)
        if key < node.key:
            node.left = self._insert(node.left, key)
        elif key > node.key:
            node.right = self._insert(node.right, key)
        else:
            return node                   # no duplicates
        refresh(node)
        return self._rebalance(node)

    def _rebalance(self, node):
        bf = balance_factor(node)

        if bf > 1:                        # LEFT heavy
            if balance_factor(node.left) < 0:
                if self.trace:
                    print(f"      left-right at {node.key}: "
                          f"rotate left at {node.left.key}, then right at {node.key}")
                node.left = self.rotate_left(node.left)
            elif self.trace:
                print(f"      left-left at {node.key}: rotate right at {node.key}")
            return self.rotate_right(node)

        if bf < -1:                       # RIGHT heavy
            if balance_factor(node.right) > 0:
                if self.trace:
                    print(f"      right-left at {node.key}: "
                          f"rotate right at {node.right.key}, then left at {node.key}")
                node.right = self.rotate_right(node.right)
            elif self.trace:
                print(f"      right-right at {node.key}: rotate left at {node.key}")
            return self.rotate_left(node)

        return node                       # -1, 0 or +1: nothing to do

    # ---- the usual BST reading operations --------------------------------
    def in_order(self):
        out = []

        def walk(n):
            if n is None:
                return
            walk(n.left)
            out.append(n.key)
            walk(n.right)

        walk(self.root)
        return out

    def height(self):
        return height(self.root)

    def is_balanced(self):
        """Every node's balance factor is -1, 0 or +1, and every stored
        height is right. Checking the heights too is what catches a rotation
        that forgot to refresh one."""
        def check(n):
            if n is None:
                return True
            if abs(balance_factor(n)) > 1:
                return False
            if n.height != 1 + max(height(n.left), height(n.right)):
                return False
            return check(n.left) and check(n.right)

        return check(self.root)

    def draw(self):
        def walk(n, depth):
            if n is None:
                return
            walk(n.right, depth + 1)
            print("      " + "        " * depth
                  + f"{n.key} (h{n.height} bf{balance_factor(n):+d})")
            walk(n.left, depth + 1)

        if self.root is None:
            print("      (empty)")
        else:
            walk(self.root, 0)


print("the four cases, one at a time, each from three keys")
print()
for name, keys in (("left-left,   rotate right", [30, 20, 10]),
                   ("right-right, rotate left", [10, 20, 30]),
                   ("left-right,  two rotations", [30, 10, 20]),
                   ("right-left,  two rotations", [10, 30, 20])):
    print(f"  {name}: insert {keys}")
    t = AVL(trace=True)
    for k in keys:
        t.insert(k)
    t.draw()
    print(f"      height {t.height()}, {t.rotations} rotation(s), "
          f"balanced: {t.is_balanced()}, in-order {t.in_order()}")
    print()
munotes.in231

Practical 18: AVL Trees and Rebalancing

the four cases, one at a time, each from three keys

  left-left,   rotate right: insert [30, 20, 10]
      left-left at 30: rotate right at 30
              30 (h0 bf+0)
      20 (h1 bf+0)
              10 (h0 bf+0)
      height 1, 1 rotation(s), balanced: True, in-order [10, 20, 30]

  right-right, rotate left: insert [10, 20, 30]
      right-right at 10: rotate left at 10
              30 (h0 bf+0)
      20 (h1 bf+0)
              10 (h0 bf+0)
      height 1, 1 rotation(s), balanced: True, in-order [10, 20, 30]

  left-right,  two rotations: insert [30, 10, 20]
      left-right at 30: rotate left at 10, then right at 30
              30 (h0 bf+0)
      20 (h1 bf+0)
              10 (h0 bf+0)
      height 1, 2 rotation(s), balanced: True, in-order [10, 20, 30]

  right-left,  two rotations: insert [10, 30, 20]
      right-left at 10: rotate right at 30, then left at 10
              30 (h0 bf+0)
      20 (h1 bf+0)
              10 (h0 bf+0)
      height 1, 2 rotation(s), balanced: True, in-order [10, 20, 30]

All four end with the same tree, 20 at the root with 10 and 30 as its children, height 1. That is the thing to notice: whatever order the three keys arrive in, the AVL tree is the same, and a plain binary search tree would have given four different shapes with heights 2, 2, 2 and 2 for the degenerate ones.

The drawing prints h for the stored height and bf for the balance factor at every node, so the rule can be checked by eye: every bf is +0 after the rotation.

munotes.in232

Practical 18: AVL Trees and Rebalancing

LL and RR take one rotation; LR and RL take two. The counts in the run say so, and that is the answer to the standard question "how many rotations does an insertion need". One insertion needs at most one rebalancing, which is at most two rotations. Deletion is different, and the third program shows why.

What a rotation actually does

Right rotation at y, with x its left child:

x = y.left
y.left = x.right      # x's right subtree becomes y's left
x.right = y           # y becomes x's right child
refresh(y); refresh(x)
return x              # x is the new root of this subtree

Three assignments. And the order matters: y.left must be read into x before it is overwritten, and x.right must be moved to y.left before x.right is overwritten. Getting those round the wrong way loses a subtree, and it is the same care as reverse in [Practical 12: Singly Linked Lists].

refresh(y) before refresh(x), because x is now above y and its height depends on y's. A program that refreshes them in the other order stores a stale height, the balance factors go wrong higher up, and the tree becomes illegal without any rotation being visibly wrong. That is why is_balanced() in this chapter checks the stored heights as well as the balance factors: it is the only check that catches this.

Observing the rebalancing, and the comparison

class Node:
    def __init__(self, key):
        self.key = key
        self.left = None
        self.right = None
        self.height = 0            # a leaf has height 0, counted in EDGES

    def __repr__(self):
        return f"Node({self.key})"


def height(node):
    """-1 for an empty subtree, so a leaf's height comes out 0."""
    return -1 if node is None else node.height


def balance_factor(node):
    """left height minus right height. Legal values are -1, 0 and +1."""
    return 0 if node is None else height(node.left) - height(node.right)


def refresh(node):
    node.height = 1 + max(height(node.left), height(node.right))


class AVL:
    """A binary search tree that rebalances itself on every insertion,
    so that no node's two subtrees ever differ in height by more than 1."""

    def __init__(self, keys=(), trace=False):
        self.root = None
        self.count = 0
        self.rotations = 0
        self.trace = trace
        for k in keys:
            self.insert(k)

    def __len__(self):
        return self.count

    # ---- the two rotations, from which all four cases are built ----------
    def rotate_right(self, y):
        """y goes down to the right, its left child x comes up."""
        x = y.left
        y.left = x.right
        x.right = y
        refresh(y)
        refresh(x)
        self.rotations += 1
        return x                          # x is the new root of this subtree

    def rotate_left(self, x):
        """x goes down to the left, its right child y comes up."""
        y = x.right
        x.right = y.left
        y.left = x
        refresh(x)
        refresh(y)
        self.rotations += 1
        return y

    # ---- insertion, which is a BST insert plus one rebalancing step ------
    def insert(self, key):
        self.root = self._insert(self.root, key)

    def _insert(self, node, key):
        if node is None:
            self.count += 1
            return Node(key)
        if key < node.key:
            node.left = self._insert(node.left, key)
        elif key > node.key:
            node.right = self._insert(node.right, key)
        else:
            return node                   # no duplicates
        refresh(node)
        return self._rebalance(node)

    def _rebalance(self, node):
        bf = balance_factor(node)

        if bf > 1:                        # LEFT heavy
            if balance_factor(node.left) < 0:
                if self.trace:
                    print(f"      left-right at {node.key}: "
                          f"rotate left at {node.left.key}, then right at {node.key}")
                node.left = self.rotate_left(node.left)
            elif self.trace:
                print(f"      left-left at {node.key}: rotate right at {node.key}")
            return self.rotate_right(node)

        if bf < -1:                       # RIGHT heavy
            if balance_factor(node.right) > 0:
                if self.trace:
                    print(f"      right-left at {node.key}: "
                          f"rotate right at {node.right.key}, then left at {node.key}")
                node.right = self.rotate_right(node.right)
            elif self.trace:
                print(f"      right-right at {node.key}: rotate left at {node.key}")
            return self.rotate_left(node)

        return node                       # -1, 0 or +1: nothing to do

    # ---- the usual BST reading operations --------------------------------
    def in_order(self):
        out = []

        def walk(n):
            if n is None:
                return
            walk(n.left)
            out.append(n.key)
            walk(n.right)

        walk(self.root)
        return out

    def height(self):
        return height(self.root)

    def is_balanced(self):
        """Every node's balance factor is -1, 0 or +1, and every stored
        height is right. Checking the heights too is what catches a rotation
        that forgot to refresh one."""
        def check(n):
            if n is None:
                return True
            if abs(balance_factor(n)) > 1:
                return False
            if n.height != 1 + max(height(n.left), height(n.right)):
                return False
            return check(n.left) and check(n.right)

        return check(self.root)

    def draw(self):
        def walk(n, depth):
            if n is None:
                return
            walk(n.right, depth + 1)
            print("      " + "        " * depth
                  + f"{n.key} (h{n.height} bf{balance_factor(n):+d})")
            walk(n.left, depth + 1)

        if self.root is None:
            print("      (empty)")
        else:
            walk(self.root, 0)


class PlainBST:
    """No rebalancing at all, for comparison."""

    def __init__(self, keys=()):
        self.root = None
        for k in keys:
            self.insert(k)

    def insert(self, key):
        node = Node(key)
        if self.root is None:
            self.root = node
            return
        here = self.root
        while True:
            if key < here.key:
                if here.left is None:
                    here.left = node
                    return
                here = here.left
            elif key > here.key:
                if here.right is None:
                    here.right = node
                    return
                here = here.right
            else:
                return

    def height(self):
        def down(n):
            return -1 if n is None else 1 + max(down(n.left), down(n.right))
        return down(self.root)


keys = [10, 20, 30, 40, 50, 25]
print("inserting", keys, "into an AVL tree, one at a time")
t = AVL(trace=True)
for k in keys:
    print(f"    insert {k}")
    t.insert(k)
    print(f"      height now {t.height()}, balanced {t.is_balanced()}")
print()
print("  the finished tree:")
t.draw()
print(f"  height {t.height()}, {t.rotations} rotations in all")
print("  in-order:", t.in_order())

print()
print("the SAME keys with no rebalancing")
p = PlainBST(keys)
print("  height", p.height(), "against the AVL tree's", t.height())

print()
print("sorted input, which is the worst case for a plain tree")
print(f"  {'keys':>6}{'plain BST':>12}{'AVL':>6}{'rotations':>11}{'log2(n+1)-1':>13}")
import math
for n in (7, 15, 31, 63, 127, 255):
    plain = PlainBST(range(n))
    avl = AVL(range(n))
    best = int(math.log2(n + 1)) - 1
    print(f"  {n:>6}{plain.height():>12}{avl.height():>6}"
          f"{avl.rotations:>11}{best:>13}")
    assert avl.is_balanced(), "the AVL tree must stay balanced"
    assert avl.in_order() == list(range(n)), "and must hold the same keys"

print()
print("every AVL tree above was checked balanced, and its in-order walk")
print("checked to be exactly the keys in order. On sorted input the heights")
print("come out EXACTLY at the perfect log2(n + 1) - 1, in the last column.")
munotes.in233

Practical 18: AVL Trees and Rebalancing

inserting [10, 20, 30, 40, 50, 25] into an AVL tree, one at a time
    insert 10
      height now 0, balanced True
    insert 20
      height now 1, balanced True
    insert 30
      right-right at 10: rotate left at 10
      height now 1, balanced True
    insert 40
      height now 2, balanced True
    insert 50
      right-right at 30: rotate left at 30
      height now 2, balanced True
    insert 25
      right-left at 20: rotate right at 40, then left at 20
      height now 2, balanced True

  the finished tree:
                      50 (h0 bf+0)
              40 (h1 bf-1)
      30 (h2 bf+0)
                      25 (h0 bf+0)
              20 (h1 bf+0)
                      10 (h0 bf+0)
  height 2, 4 rotations in all
  in-order: [10, 20, 25, 30, 40, 50]

the SAME keys with no rebalancing
  height 4 against the AVL tree's 2

sorted input, which is the worst case for a plain tree
    keys   plain BST   AVL  rotations  log2(n+1)-1
       7           6     2          4            2
      15          14     3         11            3
      31          30     4         26            4
      63          62     5         57            5
     127         126     6        120            6
     255         254     7        247            7

every AVL tree above was checked balanced, and its in-order walk
checked to be exactly the keys in order. On sorted input the heights
come out EXACTLY at the perfect log2(n + 1) - 1, in the last column.
munotes.in234

Practical 18: AVL Trees and Rebalancing

Following the six insertions

InsertWhat happenedHeight after
10the root0
20to its right, bf -1, legal1
30bf at 10 becomes -2, RR, rotate left at 101
40legal2
50bf at 30 becomes -2, RR, rotate left at 302
25bf at 20 becomes -2 and its right child is left heavy, RL, two rotations2

Six keys, four rotations, and the height never exceeded 2. The plain tree on the same keys reached height 4, which for six keys is nearly the worst possible.

Sorted input, which was the whole problem

The table at the end of the run is the answer to the chapter:

KeysPlain BST heightAVL heightRotationslog2(n + 1) - 1
76242
15143113
31304264
63625575
12712661206
25525472477
munotes.in235

Practical 18: AVL Trees and Rebalancing

Three things in that table, and the third is the one worth noticing.

The AVL height is logarithmic and the plain height is linear. 255 keys at height 7 against

  1. The operations on the first are 8 comparisons and on the second up to 255.

The rotations are about n. 247 rotations for 255 insertions: roughly one per insertion. A rotation is three pointer assignments, so the cost of keeping the tree balanced is small and constant per insertion, and that is why it is worth doing.

On sorted input the AVL height is exactly the perfect one. The last two columns are identical at every size. The AVL bound allows about 1.44 log2 n, so an AVL tree may be up to 44 per cent taller than perfect in the worst case; on sorted input it comes out perfect, because every insertion goes to the same side and the rotations rebuild the tree from the middle. That is a measured fact and it is the best possible advertisement for the structure.

The last column is what a perfectly balanced tree of n keys achieves. Do not write that an AVL tree is always perfectly balanced: it is not, and the word for what it guarantees is height balanced.

Deletion, and why it is harder than insertion

MU's bullet asks only for insertion. Deletion is worth having anyway, because an examiner may ask for it and because it shows the one respect in which AVL trees are harder than they look.

class Node:
    def __init__(self, key):
        self.key = key
        self.left = None
        self.right = None
        self.height = 0            # a leaf has height 0, counted in EDGES

    def __repr__(self):
        return f"Node({self.key})"


def height(node):
    """-1 for an empty subtree, so a leaf's height comes out 0."""
    return -1 if node is None else node.height


def balance_factor(node):
    """left height minus right height. Legal values are -1, 0 and +1."""
    return 0 if node is None else height(node.left) - height(node.right)


def refresh(node):
    node.height = 1 + max(height(node.left), height(node.right))


class AVL:
    """A binary search tree that rebalances itself on every insertion,
    so that no node's two subtrees ever differ in height by more than 1."""

    def __init__(self, keys=(), trace=False):
        self.root = None
        self.count = 0
        self.rotations = 0
        self.trace = trace
        for k in keys:
            self.insert(k)

    def __len__(self):
        return self.count

    # ---- the two rotations, from which all four cases are built ----------
    def rotate_right(self, y):
        """y goes down to the right, its left child x comes up."""
        x = y.left
        y.left = x.right
        x.right = y
        refresh(y)
        refresh(x)
        self.rotations += 1
        return x                          # x is the new root of this subtree

    def rotate_left(self, x):
        """x goes down to the left, its right child y comes up."""
        y = x.right
        x.right = y.left
        y.left = x
        refresh(x)
        refresh(y)
        self.rotations += 1
        return y

    # ---- insertion, which is a BST insert plus one rebalancing step ------
    def insert(self, key):
        self.root = self._insert(self.root, key)

    def _insert(self, node, key):
        if node is None:
            self.count += 1
            return Node(key)
        if key < node.key:
            node.left = self._insert(node.left, key)
        elif key > node.key:
            node.right = self._insert(node.right, key)
        else:
            return node                   # no duplicates
        refresh(node)
        return self._rebalance(node)

    def _rebalance(self, node):
        bf = balance_factor(node)

        if bf > 1:                        # LEFT heavy
            if balance_factor(node.left) < 0:
                if self.trace:
                    print(f"      left-right at {node.key}: "
                          f"rotate left at {node.left.key}, then right at {node.key}")
                node.left = self.rotate_left(node.left)
            elif self.trace:
                print(f"      left-left at {node.key}: rotate right at {node.key}")
            return self.rotate_right(node)

        if bf < -1:                       # RIGHT heavy
            if balance_factor(node.right) > 0:
                if self.trace:
                    print(f"      right-left at {node.key}: "
                          f"rotate right at {node.right.key}, then left at {node.key}")
                node.right = self.rotate_right(node.right)
            elif self.trace:
                print(f"      right-right at {node.key}: rotate left at {node.key}")
            return self.rotate_left(node)

        return node                       # -1, 0 or +1: nothing to do

    # ---- the usual BST reading operations --------------------------------
    def in_order(self):
        out = []

        def walk(n):
            if n is None:
                return
            walk(n.left)
            out.append(n.key)
            walk(n.right)

        walk(self.root)
        return out

    def height(self):
        return height(self.root)

    def is_balanced(self):
        """Every node's balance factor is -1, 0 or +1, and every stored
        height is right. Checking the heights too is what catches a rotation
        that forgot to refresh one."""
        def check(n):
            if n is None:
                return True
            if abs(balance_factor(n)) > 1:
                return False
            if n.height != 1 + max(height(n.left), height(n.right)):
                return False
            return check(n.left) and check(n.right)

        return check(self.root)

    def draw(self):
        def walk(n, depth):
            if n is None:
                return
            walk(n.right, depth + 1)
            print("      " + "        " * depth
                  + f"{n.key} (h{n.height} bf{balance_factor(n):+d})")
            walk(n.left, depth + 1)

        if self.root is None:
            print("      (empty)")
        else:
            walk(self.root, 0)


    # ---- deletion, which is the BST deletion plus rebalancing all the
    # way back up the path, not just once
    def delete(self, key):
        self.root, removed = self._delete(self.root, key)
        if not removed:
            raise KeyError(f"{key} is not in the tree")
        self.count -= 1

    def _delete(self, node, key):
        if node is None:
            return None, False

        if key < node.key:
            node.left, removed = self._delete(node.left, key)
        elif key > node.key:
            node.right, removed = self._delete(node.right, key)
        else:
            removed = True
            if node.left is None:
                return node.right, True
            if node.right is None:
                return node.left, True
            here = node.right                  # the in-order successor
            while here.left is not None:
                here = here.left
            node.key = here.key
            node.right, _ = self._delete(node.right, here.key)

        if not removed:
            return node, False
        refresh(node)
        return self._rebalance(node), True


AVL.delete = AVL.delete
AVL._delete = AVL._delete

keys = [50, 30, 70, 20, 40, 60, 80, 10]
t = AVL(keys, trace=True)
print("the tree, built from", keys)
t.draw()
print(f"  height {t.height()}, balanced {t.is_balanced()}, "
      f"{t.rotations} rotation(s) so far")

print()
print("delete 60, a leaf on the right. That makes 70 left-heavy... it is not,")
print("but the node above may be, so the check runs all the way up.")
t.delete(60)
t.draw()
print(f"  height {t.height()}, balanced {t.is_balanced()}, in-order {t.in_order()}")

print()
print("delete 70, which now has one child")
t.delete(70)
t.draw()
print(f"  height {t.height()}, balanced {t.is_balanced()}, in-order {t.in_order()}")
print(f"  rotations in all: {t.rotations}")

print()
print("delete 50, the root, which has two children")
t.delete(50)
t.draw()
print(f"  height {t.height()}, balanced {t.is_balanced()}, in-order {t.in_order()}")

print()
try:
    t.delete(99)
except KeyError as e:
    print("delete a key that is not there: KeyError:", e)

print()
print("and a deletion that needs SEVERAL rotations, which an insertion never")
print("does: one insertion is rebalanced at most once, and a deletion may be")
print("rebalanced at every level on the way back up.")
order = [4, 40, 77, 12, 62, 3, 30, 90, 15, 64, 79, 85, 63,
         33, 2, 48, 39, 19, 89, 26, 67, 22, 44, 57]
u = AVL(order)
print(f"  a tree of {len(u)} keys, height {u.height()}, "
      f"built with {u.rotations} rotations")
before = u.rotations
u.delete(77)
print(f"  deleting ONE key, 77, took {u.rotations - before} rotations")
print(f"  height {u.height()}, balanced {u.is_balanced()}")
print(f"  and it still holds every other key: "
      f"{u.in_order() == [k for k in sorted(order) if k != 77]}")
munotes.in236

Practical 18: AVL Trees and Rebalancing

the tree, built from [50, 30, 70, 20, 40, 60, 80, 10]
                      80 (h0 bf+0)
              70 (h1 bf+0)
                      60 (h0 bf+0)
      50 (h3 bf+1)
                      40 (h0 bf+0)
              30 (h2 bf+1)
                      20 (h1 bf+1)
                              10 (h0 bf+0)
  height 3, balanced True, 0 rotation(s) so far

delete 60, a leaf on the right. That makes 70 left-heavy... it is not,
but the node above may be, so the check runs all the way up.
                      80 (h0 bf+0)
              70 (h1 bf-1)
      50 (h3 bf+1)
                      40 (h0 bf+0)
              30 (h2 bf+1)
                      20 (h1 bf+1)
                              10 (h0 bf+0)
  height 3, balanced True, in-order [10, 20, 30, 40, 50, 70, 80]

delete 70, which now has one child
      left-left at 50: rotate right at 50
                      80 (h0 bf+0)
              50 (h1 bf+0)
                      40 (h0 bf+0)
      30 (h2 bf+0)
              20 (h1 bf+1)
                      10 (h0 bf+0)
  height 2, balanced True, in-order [10, 20, 30, 40, 50, 80]
  rotations in all: 1

delete 50, the root, which has two children
              80 (h1 bf+1)
                      40 (h0 bf+0)
      30 (h2 bf+0)
              20 (h1 bf+1)
                      10 (h0 bf+0)
  height 2, balanced True, in-order [10, 20, 30, 40, 80]

delete a key that is not there: KeyError: '99 is not in the tree'

and a deletion that needs SEVERAL rotations, which an insertion never
does: one insertion is rebalanced at most once, and a deletion may be
rebalanced at every level on the way back up.
  a tree of 24 keys, height 5, built with 13 rotations
  deleting ONE key, 77, took 3 rotations
  height 4, balanced True
  and it still holds every other key: True
munotes.in237

Practical 18: AVL Trees and Rebalancing

The difference from insertion

An insertion is rebalanced at most once. Fixing the lowest unbalanced node restores the height that subtree had before the insertion, so nothing above it can still be unbalanced.

munotes.in238

Practical 18: AVL Trees and Rebalancing

A deletion may be rebalanced at every level. Removing a node can make a subtree shorter, and a rotation that fixes one node can leave its parent shorter too, and so on all the way to the root. The run shows it: deleting one key from a 24-key tree took three rotations.

That is why _delete calls _rebalance on the way back up from every level, exactly as _insert does, and why the bound is O(log n) rotations for a deletion against at most 2 for an insertion. Both are still O(log n) overall, because the walk down is O(log n) anyway.

The rest of deletion is the binary search tree's three cases, unchanged from [Practical 17: Binary Search Trees and Tree Traversals]: a leaf goes, a single child is lifted, and a node with two children takes its in-order successor's key. The AVL part is only the refresh and the _rebalance on the way back.

AVL against the alternatives

Plain BSTAVL treeRed-black treeHash table
SearchO(h), h up to nO(log n)O(log n)O(1) average
InsertO(h)O(log n)O(log n)O(1) average
DeleteO(h)O(log n)O(log n)O(1) average
Height boundnone1.44 log2 n2 log2 nn/a
Rotations per insertnoneat most 2at most 2n/a
Rotations per deletenoneO(log n)at most 3n/a
Keys in sorted orderin-order walkin-order walkin-order walkno
Range queryyesyesyesno
Used bynothing seriousdatabases, some file systemsJava TreeMap, C++ std::map, Linux kernelPython dict

AVL trees are the most strictly balanced of the practical self-balancing trees, so they are the fastest to search and the most work to update. Red-black trees are looser, so they are a little taller and cheaper to change, which is why they are what standard libraries ship. The rule of thumb: AVL for data that is read far more than it is written, red-black for the general case.

And the row that matters most against a hash table: a tree keeps its keys in order. A hash table is faster for one key and cannot answer "give me every key between 20 and 40", cannot give you the smallest key, and cannot be walked in sorted order. That is the reason trees survive, and it is the subject of [Practical 20: Hashing and Collision Handling].

munotes.in239

Practical 18: AVL Trees and Rebalancing

Procedure

  1. Write the first program and run it. Draw each of the four cases on paper before and after, with

the balance factors.

  1. Insert 10, 20, 30, 40, 50 one at a time and predict each rotation before running it.
  2. Swap the two refresh calls in rotate_right so that x is refreshed before y. Run the

comparison program: is_balanced() now reports False, because the stored heights are stale. That is the bug the height check exists to catch.

  1. Remove the balance_factor(node.left) < 0 test, so every left-heavy node is fixed with a

single right rotation. Insert 30, 10, 20 and confirm the tree is still unbalanced afterwards.

  1. Write the comparison and extend the table to 511 and 1023 keys.
  2. Insert 1 to 100 in order, then in reverse order, then shuffled, and print the height each time.

All three should be 6.

  1. Write the deletion and delete every key of a twenty-key tree one at a time, checking

is_balanced() and the in-order walk after each. That loop is the best test in this chapter.

Result

An AVL tree was implemented with the balance factor, the two single rotations and all four rebalancing cases. The four cases were each produced from three keys and all four gave the same balanced tree of height 1, taking one rotation for LL and RR and two for LR and RL. Six keys inserted one at a time were followed through four rotations with the height never exceeding 2, where a plain tree on the same keys reached 4. On sorted input of up to 255 keys the AVL height was measured as exactly log2(n + 1) - 1, the perfect value, against n - 1 for the plain tree, at a cost of about one rotation per insertion. Deletion was implemented and shown to need rebalancing at several levels, three rotations for one deletion from a 24-key tree, where an insertion needs at most one rebalancing.

Where marks are lost

  • Balance factor defined the other way round. It is left minus right here; some books use

right minus left. Say which, and then be consistent.

  • Checking the rule only at the root. It must hold at every node.
  • Confusing LR with RR. The case is named for the two steps down towards the new key: the

node's heaviness first, then its child's.

  • Using a single rotation for LR or RL. It does not fix the tree; step 4 of the procedure

proves it.

  • Not refreshing the heights, or refreshing them in the wrong order. The lower node first.
  • Recomputing the height by walking the subtree instead of storing it. That makes every
munotes.in240

Practical 18: AVL Trees and Rebalancing

insertion O(n log n) and throws the whole point away.

  • Saying an AVL tree is perfectly balanced. It is height balanced: the bound is about 1.44

log2 n.

  • Saying a deletion needs at most one rotation. An insertion does; a deletion may need one at

every level.

  • No drawing and no balance factors. MU's bullet says observe the rebalancing.

For the journal

Write the aim, MU's own wording, the definition of the balance factor and the AVL rule, and the table of the four cases with their fixes. Then draw each of the four cases before and after, with the balance factors on every node, because those four diagrams are the heart of the marks. Then the six-key run with its four rotations and the height after each insertion, and the comparison table against the plain tree at 7, 15, 31, 63, 127 and 255 keys. Add the deletion example with the three rotations and the sentence about why deletion is harder than insertion. The conclusion: one extra integer per node and two rotation functions turn a structure whose height can be n into one whose height is always about log n, at a cost of roughly one rotation per insertion.

Quick revision

  • Balance factor = height of the left subtree minus height of the right. The AVL rule is that it

is -1, 0 or +1 at every node.

  • Height is counted in edges: a leaf is 0 and an empty subtree -1.
  • Four cases: LL fixed by one right rotation, RR by one left, LR by left then right, RL by right

then left. Named for the node's heaviness then its child's.

  • A rotation is three pointer assignments plus two height refreshes, lower node first.
  • A rotation does not change the in-order sequence, which is why it is safe.
  • One insertion needs at most one rebalancing, so at most two rotations. A deletion may need

rebalancing at every level, O(log n) rotations.

  • The height bound is about 1.44 log2 n, so an AVL tree is height balanced, not perfectly

balanced.

  • Measured on sorted input: plain BST heights 6, 14, 30, 62, 126, 254 for 7 to 255 keys; AVL

heights 2, 3, 4, 5, 6, 7, which is exactly the perfect log2(n + 1) - 1.

  • The rotations cost about one per insertion: 247 for 255 sorted insertions.
  • Store the height in the node. Recomputing it makes every insertion O(n log n).
  • Check an AVL tree by verifying every balance factor and every stored height, and that the

in-order walk is sorted.

munotes.in241

Practical 18: AVL Trees and Rebalancing

  • Red-black trees are looser and cheaper to update, which is why standard libraries use them. AVL

suits data read far more than written.

Questions you should be able to answer

1. Define the balance factor and state the AVL rule. The height of the left subtree minus the height of the right subtree. The rule is that it must be -1, 0 or +1 at every node in the tree.

2. Name the four rebalancing cases and the rotations each needs. LL, one right rotation at the unbalanced node. RR, one left rotation. LR, a left rotation at the child then a right at the node. RL, a right rotation at the child then a left at the node.

3. How is the case named? By the two steps down towards the new key: the unbalanced node's heavy side first, then that child's heavy side. LR means left heavy with a right-heavy left child.

4. How many rotations does one insertion need, and one deletion? An insertion needs at most one rebalancing, so at most two rotations. A deletion may be rebalanced at every level on the way back up, so O(log n) rotations; on the page, three for one deletion from a 24-key tree.

5. Why must the heights be refreshed lower node first after a rotation? Because after the rotation the node that came up sits above the one that went down, so its height depends on the other's. Refreshing them the other way round stores a stale height and the tree becomes illegal with no rotation visibly wrong.

6. Does a rotation change the order of the keys? No. It changes the shape and leaves the in-order sequence exactly as it was, which is why the tree is still a binary search tree afterwards.

7. Seven keys inserted in sorted order: what is the height of a plain BST and of an AVL tree? Six and two. At 255 keys it is 254 and 7.

8. Is an AVL tree perfectly balanced? No, it is height balanced: its height is at most about 1.44 log2 n. On sorted input it happens to come out at exactly the perfect height, which was measured here up to 255 keys.

9. Why do standard libraries use red-black trees rather than AVL trees? Because a red-black tree's balance condition is looser, so it needs fewer rotations to update, at the cost of being up to twice as tall. AVL is preferable when the data is read far more often than it is changed.

10. What can an AVL tree do that a hash table cannot? Give the keys in sorted order, answer a range query, and give the smallest or largest key. A hash table is faster for one key and has no order at all.

Contents This chapter on its own page

munotes.in242

Chapter Twenty-Seven

Practical 18 continued: Heaps and Priority Queues

Syllabus topic Module 2, "Construct min-heaps or max-heaps and simulate priority queues. Use priority queues to manage task priorities (e.g., patient triage, job scheduling)."

Aim

To construct a min-heap and a max-heap in an array, to build one from an unordered array, to sort with a heap, and to implement a priority queue and use it for patient triage and job scheduling.

What you need to know before you start

A heap is a complete binary tree in which every parent stands in a fixed relation to its children:

  • in a min-heap, every parent is no larger than either child, so the smallest item is at

the root;

  • in a max-heap, every parent is no smaller than either child, so the largest item is at

the root.

Complete means every level is full except perhaps the last, which is filled from the left. That is the property that lets a heap live in an array with no links at all.

A heap is not a binary search tree. In a BST the left subtree is smaller and the right larger, so an in-order walk is sorted. In a heap the two children are simply both larger (or both smaller) than the parent, in no order relative to each other, and no traversal of a heap is sorted. The only item a heap can find quickly is the one at the root.

Binary search treeHeap
The ruleleft smaller, right largerboth children larger (min-heap)
Shapeanycomplete, always
Stored asnodes and linksan array
Find the smallestwalk left, O(log n)index 0, O(1)
Find any keyO(log n)O(n), it is a scan
Sorted walkin-order, O(n)not possible
Used forlookup by key, ranges, orderpriority queues, heapsort, the k smallest

The array is the tree

This is the idea to take away from the chapter. Number the nodes level by level from 0, and then for the item at index i:

RelativeIndex
parent(i - 1) // 2
left child2 * i + 1
right child2 * i + 2

No pointers, no nodes, no None checks: a child exists if its index is less than the length. That is why a heap is the cheapest of all the tree structures in memory, and why heapq in Python operates on a plain list.

The two operations everything is built from

Sift up, also called bubble up or percolate up. Used by insertion: put the new item at the end of the array, which keeps the tree complete, and while it is smaller than its parent, swap the two. It rises at most one level per swap, so it costs O(log n).

Sift down, also called sink or percolate down. Used by extraction: the root is removed, the last item is moved to the root, and while it is larger than its smaller child, swap them. Also O(log n).

munotes.in243

Practical 18 continued: Heaps and Priority Queues

The min-heap

class MinHeap:
    """A complete binary tree kept in an ARRAY. No nodes, no links:
    the tree is in the arithmetic."""

    def __init__(self, items=()):
        self.a = []
        for x in items:
            self.insert(x)

    # ---- where the relatives are, which is the whole idea ----------------
    @staticmethod
    def parent(i):
        return (i - 1) // 2

    @staticmethod
    def left(i):
        return 2 * i + 1

    @staticmethod
    def right(i):
        return 2 * i + 2

    def __len__(self):
        return len(self.a)

    def is_empty(self):
        return not self.a

    def peek(self):
        if not self.a:
            raise IndexError("the heap is empty")
        return self.a[0]                       # the smallest is ALWAYS at 0

    # ---- putting one in: sift UP -----------------------------------------
    def insert(self, x):
        self.a.append(x)                       # at the end, keeping it complete
        i = len(self.a) - 1
        while i > 0 and self.a[i] < self.a[self.parent(i)]:
            p = self.parent(i)
            self.a[i], self.a[p] = self.a[p], self.a[i]
            i = p

    # ---- taking the smallest out: sift DOWN ------------------------------
    def extract_min(self):
        if not self.a:
            raise IndexError("the heap is empty")
        smallest = self.a[0]
        last = self.a.pop()                    # the last leaf
        if self.a:
            self.a[0] = last                   # put it at the root
            self.sift_down(0)                  # and let it sink
        return smallest

    def sift_down(self, i):
        n = len(self.a)
        while True:
            l, r, small = self.left(i), self.right(i), i
            if l < n and self.a[l] < self.a[small]:
                small = l
            if r < n and self.a[r] < self.a[small]:
                small = r
            if small == i:
                return
            self.a[i], self.a[small] = self.a[small], self.a[i]
            i = small

    # ---- building a heap from an unordered array in O(n) -----------------
    @classmethod
    def heapify(cls, items):
        """Sift DOWN from the last parent to the root. O(n), not O(n log n),
        because most of the nodes are leaves and have nothing to do."""
        h = cls()
        h.a = list(items)
        for i in range(len(h.a) // 2 - 1, -1, -1):
            h.sift_down(i)
        return h

    def is_heap(self):
        """Every parent is no larger than either child."""
        n = len(self.a)
        for i in range(n):
            for c in (self.left(i), self.right(i)):
                if c < n and self.a[i] > self.a[c]:
                    return False
        return True

    def draw(self):
        if not self.a:
            print("      (empty)")
            return
        level, i = 0, 0
        while i < len(self.a):
            width = 2 ** level
            row = self.a[i:i + width]
            print("      level %d: %s" % (level, " ".join(str(x) for x in row)))
            i += width
            level += 1


print("the array is the tree: for the item at index i,")
print("  parent (i - 1) // 2, left child 2i + 1, right child 2i + 2")
print()

h = MinHeap()
for x in (40, 20, 60, 10, 50, 30, 5):
    h.insert(x)
    print(f"  insert {x:<3} -> {h.a}")
print()
print("  as levels:")
h.draw()
print("  a valid heap:", h.is_heap(), " the smallest is at index 0:", h.peek())

print()
print("  checking the arithmetic on this heap")
print(f"    {'i':>3}{'item':>6}{'parent':>8}{'left':>7}{'right':>7}")
for i in range(len(h.a)):
    p = h.a[h.parent(i)] if i > 0 else None
    l = h.a[h.left(i)] if h.left(i) < len(h.a) else None
    r = h.a[h.right(i)] if h.right(i) < len(h.a) else None
    print(f"    {i:>3}{h.a[i]:>6}{str(p):>8}{str(l):>7}{str(r):>7}")

print()
print("  taking them out, smallest first:")
out = []
while not h.is_empty():
    out.append(h.extract_min())
    print(f"    extract -> {out[-1]:<3} array now {h.a}")
print("  the order they came out:", out, " sorted:", out == sorted(out))

print()
unordered = [40, 20, 60, 10, 50, 30, 5, 70, 15]
print("building from an unordered array in one pass:", unordered)
g = MinHeap.heapify(unordered)
print("  after heapify:", g.a, " a valid heap:", g.is_heap())
g.draw()
print()
print("  one at a time would give a different array holding the same items:")
one_at_a_time = MinHeap(unordered)
print("  ", one_at_a_time.a, " a valid heap:", one_at_a_time.is_heap())
print("  and both give the same sorted order when emptied:",
      sorted(g.a) == sorted(one_at_a_time.a))

print()
try:
    MinHeap().extract_min()
except IndexError as e:
    print("extract from an empty heap: IndexError:", e)
munotes.in244

Practical 18 continued: Heaps and Priority Queues

the array is the tree: for the item at index i,
  parent (i - 1) // 2, left child 2i + 1, right child 2i + 2

  insert 40  -> [40]
  insert 20  -> [20, 40]
  insert 60  -> [20, 40, 60]
  insert 10  -> [10, 20, 60, 40]
  insert 50  -> [10, 20, 60, 40, 50]
  insert 30  -> [10, 20, 30, 40, 50, 60]
  insert 5   -> [5, 20, 10, 40, 50, 60, 30]

  as levels:
      level 0: 5
      level 1: 20 10
      level 2: 40 50 60 30
  a valid heap: True  the smallest is at index 0: 5

  checking the arithmetic on this heap
      i  item  parent   left  right
      0     5    None     20     10
      1    20       5     40     50
      2    10       5     60     30
      3    40      20   None   None
      4    50      20   None   None
      5    60      10   None   None
      6    30      10   None   None

  taking them out, smallest first:
    extract -> 5   array now [10, 20, 30, 40, 50, 60]
    extract -> 10  array now [20, 40, 30, 60, 50]
    extract -> 20  array now [30, 40, 50, 60]
    extract -> 30  array now [40, 60, 50]
    extract -> 40  array now [50, 60]
    extract -> 50  array now [60]
    extract -> 60  array now []
  the order they came out: [5, 10, 20, 30, 40, 50, 60]  sorted: True

building from an unordered array in one pass: [40, 20, 60, 10, 50, 30, 5, 70, 15]
  after heapify: [5, 10, 30, 15, 50, 40, 60, 70, 20]  a valid heap: True
      level 0: 5
      level 1: 10 30
      level 2: 15 50 40 60
      level 3: 70 20

  one at a time would give a different array holding the same items:
   [5, 15, 10, 20, 50, 60, 30, 70, 40]  a valid heap: True
  and both give the same sorted order when emptied: True

extract from an empty heap: IndexError: the heap is empty
munotes.in245

Practical 18 continued: Heaps and Priority Queues

Following the insertions

Watch the array after insert 5, the last one: [5, 20, 10, 40, 50, 60, 30]. The 5 went in at index 6 and rose three times, to index 2, then 0. The printed levels show the finished shape:

level 0: 5
level 1: 20 10
level 2: 40 50 60 30

And notice that 20 is to the left of 10. In a heap that is perfectly legal: both are larger than their parent 5, and nothing says the left child must be smaller than the right. A student who expects the array to be sorted has not understood the structure, and the table of parents and children in the run is there to make the relation explicit.

Extraction, and why the last leaf goes to the root

extract_min does three things and the middle one is the one to explain:

  1. Remember a[0], which is the answer.
  2. Move the last item of the array to index 0 and shorten the array.
  3. Sift that item down until the heap property holds again.

Step 2 looks arbitrary and is the only choice that keeps the tree complete. Promoting the smaller child instead would leave a hole in the middle of the array and the index arithmetic would stop working. The last leaf is the only node that can be removed without breaking completeness, so it is the one that moves.

The run empties the heap and the items come out 5, 10, 20, 30, 40, 50, 60: sorted. That is heapsort, and it is the next program.

heapify: O(n), not O(n log n)

Building a heap by inserting n items one at a time costs O(n log n). Building it from an array already in memory costs O(n), and the difference is worth knowing.

for i in range(len(a) // 2 - 1, -1, -1):
    sift_down(i)

Start at the last parent, which is at index n//2 - 1, and sift down from there back to the root. Every index above n//2 - 1 is a leaf and a leaf is already a heap of one item, so half the array needs no work at all. Of the rest, a quarter can sink at most one level, an eighth at most two, and so on; the sum comes to less than n.

The run shows the two routes giving different arrays from the same nine items, both valid heaps, and both emptying to the same sorted order. Two valid heaps of the same items need not be the same array, which is another way of seeing that a heap is much less constrained than a search tree.

munotes.in246

Practical 18 continued: Heaps and Priority Queues

The max-heap, heapsort, and the k smallest

class MinHeap:
    """A complete binary tree kept in an ARRAY. No nodes, no links:
    the tree is in the arithmetic."""

    def __init__(self, items=()):
        self.a = []
        for x in items:
            self.insert(x)

    # ---- where the relatives are, which is the whole idea ----------------
    @staticmethod
    def parent(i):
        return (i - 1) // 2

    @staticmethod
    def left(i):
        return 2 * i + 1

    @staticmethod
    def right(i):
        return 2 * i + 2

    def __len__(self):
        return len(self.a)

    def is_empty(self):
        return not self.a

    def peek(self):
        if not self.a:
            raise IndexError("the heap is empty")
        return self.a[0]                       # the smallest is ALWAYS at 0

    # ---- putting one in: sift UP -----------------------------------------
    def insert(self, x):
        self.a.append(x)                       # at the end, keeping it complete
        i = len(self.a) - 1
        while i > 0 and self.a[i] < self.a[self.parent(i)]:
            p = self.parent(i)
            self.a[i], self.a[p] = self.a[p], self.a[i]
            i = p

    # ---- taking the smallest out: sift DOWN ------------------------------
    def extract_min(self):
        if not self.a:
            raise IndexError("the heap is empty")
        smallest = self.a[0]
        last = self.a.pop()                    # the last leaf
        if self.a:
            self.a[0] = last                   # put it at the root
            self.sift_down(0)                  # and let it sink
        return smallest

    def sift_down(self, i):
        n = len(self.a)
        while True:
            l, r, small = self.left(i), self.right(i), i
            if l < n and self.a[l] < self.a[small]:
                small = l
            if r < n and self.a[r] < self.a[small]:
                small = r
            if small == i:
                return
            self.a[i], self.a[small] = self.a[small], self.a[i]
            i = small

    # ---- building a heap from an unordered array in O(n) -----------------
    @classmethod
    def heapify(cls, items):
        """Sift DOWN from the last parent to the root. O(n), not O(n log n),
        because most of the nodes are leaves and have nothing to do."""
        h = cls()
        h.a = list(items)
        for i in range(len(h.a) // 2 - 1, -1, -1):
            h.sift_down(i)
        return h

    def is_heap(self):
        """Every parent is no larger than either child."""
        n = len(self.a)
        for i in range(n):
            for c in (self.left(i), self.right(i)):
                if c < n and self.a[i] > self.a[c]:
                    return False
        return True

    def draw(self):
        if not self.a:
            print("      (empty)")
            return
        level, i = 0, 0
        while i < len(self.a):
            width = 2 ** level
            row = self.a[i:i + width]
            print("      level %d: %s" % (level, " ".join(str(x) for x in row)))
            i += width
            level += 1


class MaxHeap(MinHeap):
    """The SAME code with the comparison turned round. A max-heap and a
    min-heap differ in one operator, which is why only one is written."""

    def insert(self, x):
        self.a.append(x)
        i = len(self.a) - 1
        while i > 0 and self.a[i] > self.a[self.parent(i)]:
            p = self.parent(i)
            self.a[i], self.a[p] = self.a[p], self.a[i]
            i = p

    def sift_down(self, i):
        n = len(self.a)
        while True:
            l, r, big = self.left(i), self.right(i), i
            if l < n and self.a[l] > self.a[big]:
                big = l
            if r < n and self.a[r] > self.a[big]:
                big = r
            if big == i:
                return
            self.a[i], self.a[big] = self.a[big], self.a[i]
            i = big

    extract_max = MinHeap.extract_min

    def is_heap(self):
        n = len(self.a)
        for i in range(n):
            for c in (self.left(i), self.right(i)):
                if c < n and self.a[i] < self.a[c]:
                    return False
        return True


items = [40, 20, 60, 10, 50, 30, 5]
print("the same seven items in a min-heap and a max-heap")
mn = MinHeap(items)
mx = MaxHeap(items)
print("  min-heap:", mn.a, " top", mn.peek(), " valid", mn.is_heap())
print("  max-heap:", mx.a, " top", mx.peek(), " valid", mx.is_heap())

print()
print("heapsort: heapify once, then take the top n times")


def heapsort(items, descending=False):
    h = (MaxHeap if descending else MinHeap).heapify(items)
    return [h.extract_min() for _ in range(len(h.a))]


data = [40, 20, 60, 10, 50, 30, 5, 70, 15]
print("  data      :", data)
print("  ascending :", heapsort(data))
print("  descending:", heapsort(data, descending=True))
print("  agrees with sorted():", heapsort(data) == sorted(data),
      heapsort(data, descending=True) == sorted(data, reverse=True))
print("  and the original list is untouched:", data)

print()
print("the k smallest, which is what a heap is really for")
big = [37, 4, 91, 12, 55, 8, 73, 26, 61, 19, 44, 2, 88, 33, 70]
k = 4
h = MinHeap.heapify(big)
print(f"  {len(big)} items, the {k} smallest:",
      [h.extract_min() for _ in range(k)])
print("  the other 11 were never fully sorted, which is the saving:")
print("  sorting all 15 costs n log n; a heap plus k extractions costs")
print("  n + k log n, and here k is 4.")
munotes.in247

Practical 18 continued: Heaps and Priority Queues

the same seven items in a min-heap and a max-heap
  min-heap: [5, 20, 10, 40, 50, 60, 30]  top 5  valid True
  max-heap: [60, 50, 40, 10, 20, 30, 5]  top 60  valid True

heapsort: heapify once, then take the top n times
  data      : [40, 20, 60, 10, 50, 30, 5, 70, 15]
  ascending : [5, 10, 15, 20, 30, 40, 50, 60, 70]
  descending: [70, 60, 50, 40, 30, 20, 15, 10, 5]
  agrees with sorted(): True True
  and the original list is untouched: [40, 20, 60, 10, 50, 30, 5, 70, 15]

the k smallest, which is what a heap is really for
  15 items, the 4 smallest: [2, 4, 8, 12]
  the other 11 were never fully sorted, which is the saving:
  sorting all 15 costs n log n; a heap plus k extractions costs
  n + k log n, and here k is 4.
munotes.in248

Practical 18 continued: Heaps and Priority Queues

A max-heap is a min-heap with the comparison turned round

MaxHeap inherits everything and overrides insert and sift_down, changing < to > in each. That is the whole difference, and it is worth saying in the journal: a max-heap and a min-heap are the same structure with the comparison reversed. In a real program the comparison would be a parameter and there would be one class.

Heapsort

def heapsort(items):
    h = MinHeap.heapify(items)
    return [h.extract_min() for _ in range(len(h.a))]

Heapify once, O(n), then extract n times at O(log n) each: O(n log n) in total, and that is the worst case as well as the average, which quicksort cannot promise.

HeapsortQuicksortMerge sort
AverageO(n log n)O(n log n)O(n log n)
Worst caseO(n log n)O(n squared)O(n log n)
Extra memoryO(1), in placeO(log n) for the recursionO(n)
Stablenonoyes
In practiceslower than quicksortusually fastestused where stability matters

Heapsort is the only one of the three that is both O(n log n) in the worst case and in place. It is slower than quicksort in practice because its memory access jumps about the array, which is unkind to the cache, and that is why standard libraries use a hybrid. Python's sorted uses Timsort, a merge sort adapted to runs that are already in order.

The version here is not in place, because it copies into a heap object. The classical in-place heapsort builds a max-heap in the array itself and then repeatedly swaps the root with the last unsorted position, which sorts ascending with no extra array at all. That is worth writing as the extension in the procedure.

The k smallest, which is what a heap is really for

The run takes the 4 smallest of 15 items. Sorting all 15 would cost n log n; heapify plus 4 extractions costs n + k log n. When k is small and n is large, that is the difference between reading a whole table and reading the top of it, and it is why "give me the ten largest" in a database uses a heap.

The priority queue, and MU's two examples

A priority queue is an abstract data type, and a heap is the usual implementation of it. Its contract:

OperationWhat it does
add(item, priority)put an item in with a priority
next_item()remove and return the item of best priority
peek()look at it without removing
is_empty(), len()as usual

It is a queue in which the order of service is not the order of arrival but the order of priority. The ordinary FIFO queue of [Practical 16: Queues and Circular Queues] is the special case where every item has the same priority.

munotes.in249

Practical 18 continued: Heaps and Priority Queues

class MinHeap:
    """A complete binary tree kept in an ARRAY. No nodes, no links:
    the tree is in the arithmetic."""

    def __init__(self, items=()):
        self.a = []
        for x in items:
            self.insert(x)

    # ---- where the relatives are, which is the whole idea ----------------
    @staticmethod
    def parent(i):
        return (i - 1) // 2

    @staticmethod
    def left(i):
        return 2 * i + 1

    @staticmethod
    def right(i):
        return 2 * i + 2

    def __len__(self):
        return len(self.a)

    def is_empty(self):
        return not self.a

    def peek(self):
        if not self.a:
            raise IndexError("the heap is empty")
        return self.a[0]                       # the smallest is ALWAYS at 0

    # ---- putting one in: sift UP -----------------------------------------
    def insert(self, x):
        self.a.append(x)                       # at the end, keeping it complete
        i = len(self.a) - 1
        while i > 0 and self.a[i] < self.a[self.parent(i)]:
            p = self.parent(i)
            self.a[i], self.a[p] = self.a[p], self.a[i]
            i = p

    # ---- taking the smallest out: sift DOWN ------------------------------
    def extract_min(self):
        if not self.a:
            raise IndexError("the heap is empty")
        smallest = self.a[0]
        last = self.a.pop()                    # the last leaf
        if self.a:
            self.a[0] = last                   # put it at the root
            self.sift_down(0)                  # and let it sink
        return smallest

    def sift_down(self, i):
        n = len(self.a)
        while True:
            l, r, small = self.left(i), self.right(i), i
            if l < n and self.a[l] < self.a[small]:
                small = l
            if r < n and self.a[r] < self.a[small]:
                small = r
            if small == i:
                return
            self.a[i], self.a[small] = self.a[small], self.a[i]
            i = small

    # ---- building a heap from an unordered array in O(n) -----------------
    @classmethod
    def heapify(cls, items):
        """Sift DOWN from the last parent to the root. O(n), not O(n log n),
        because most of the nodes are leaves and have nothing to do."""
        h = cls()
        h.a = list(items)
        for i in range(len(h.a) // 2 - 1, -1, -1):
            h.sift_down(i)
        return h

    def is_heap(self):
        """Every parent is no larger than either child."""
        n = len(self.a)
        for i in range(n):
            for c in (self.left(i), self.right(i)):
                if c < n and self.a[i] > self.a[c]:
                    return False
        return True

    def draw(self):
        if not self.a:
            print("      (empty)")
            return
        level, i = 0, 0
        while i < len(self.a):
            width = 2 ** level
            row = self.a[i:i + width]
            print("      level %d: %s" % (level, " ".join(str(x) for x in row)))
            i += width
            level += 1


from itertools import count


class PriorityQueue:
    """A priority queue over a min-heap. Entries are
    (priority, arrival number, item), so that two items of the same
    priority come out in the order they arrived: a STABLE queue."""

    def __init__(self):
        self.heap = MinHeap()
        self.arrivals = count()

    def __len__(self):
        return len(self.heap)

    def is_empty(self):
        return self.heap.is_empty()

    def add(self, item, priority):
        self.heap.insert((priority, next(self.arrivals), item))

    def next_item(self):
        if self.heap.is_empty():
            raise IndexError("the queue is empty")
        priority, _, item = self.heap.extract_min()
        return item, priority

    def peek(self):
        priority, _, item = self.heap.peek()
        return item, priority


print("MU's first example: patient triage")
print("  priority 1 is the most urgent")
triage = PriorityQueue()
arrivals = [("Sunil, sprained ankle", 4),
            ("Meera, chest pain", 1),
            ("Ravi, high fever", 3),
            ("Asha, road accident", 1),
            ("Kiran, cut finger", 5),
            ("Nikhil, breathing trouble", 2)]
for who, urgency in arrivals:
    triage.add(who, urgency)
    print(f"    arrives: {who:<28} priority {urgency}")

print()
print("  seen in this order:")
place = 1
while not triage.is_empty():
    who, urgency = triage.next_item()
    print(f"    {place}. {who:<28} priority {urgency}")
    place += 1
print("  Meera and Asha were both priority 1, and Meera arrived first,")
print("  so she is seen first. That is what the arrival number buys.")

print()
print("MU's second example: job scheduling")
jobs = PriorityQueue()
for name, priority, minutes in (("nightly backup", 5, 40),
                                ("payroll", 1, 15),
                                ("send results by email", 2, 5),
                                ("rebuild the search index", 4, 25),
                                ("clear the print queue", 2, 2)):
    jobs.add((name, minutes), priority)

clock = 0
print(f"    {'at':>4}  {'job':<26}{'priority':>9}{'minutes':>9}")
while not jobs.is_empty():
    (name, minutes), priority = jobs.next_item()
    print(f"    {clock:>4}  {name:<26}{priority:>9}{minutes:>9}")
    clock += minutes
print(f"    {clock:>4}  everything is done")

print()
print("a priority queue against the alternatives, for 5 jobs")
print("  a sorted list  : each insert costs a search plus a shift")
print("  an unsorted list: each next_item costs a scan of everything")
print("  a heap          : both cost log n, which is why it is the answer")

print()
print("and what Python provides ready-made, with a trap in it")
import heapq
h = []
for who, urgency in arrivals:
    heapq.heappush(h, (urgency, who))
order = [heapq.heappop(h)[1].split(",")[0] for _ in range(len(h))]
print("  heapq with just (priority, name):", order)
print("  Asha came out BEFORE Meera, and Meera arrived first. When the")
print("  priorities tie, heapq compares the NEXT element of the tuple, so")
print("  it broke the tie alphabetically: 'Asha' < 'Meera'.")
print()
h = []
for n, (who, urgency) in enumerate(arrivals):
    heapq.heappush(h, (urgency, n, who))
fixed = [heapq.heappop(h)[2].split(",")[0] for _ in range(len(h))]
print("  heapq with (priority, arrival, name):", fixed)
print("  now it matches the class above. The arrival number is not")
print("  decoration: without it a priority queue is not stable, and with a")
print("  payload that cannot be compared at all it raises TypeError.")
print()
class Patient:
    def __init__(self, name):
        self.name = name
h2 = []
heapq.heappush(h2, (1, Patient("Meera")))
try:
    heapq.heappush(h2, (1, Patient("Asha")))
except TypeError as e:
    print("  pushing a second object of equal priority:", type(e).__name__ + ":", e)
munotes.in250

Practical 18 continued: Heaps and Priority Queues

MU's first example: patient triage
  priority 1 is the most urgent
    arrives: Sunil, sprained ankle        priority 4
    arrives: Meera, chest pain            priority 1
    arrives: Ravi, high fever             priority 3
    arrives: Asha, road accident          priority 1
    arrives: Kiran, cut finger            priority 5
    arrives: Nikhil, breathing trouble    priority 2

  seen in this order:
    1. Meera, chest pain            priority 1
    2. Asha, road accident          priority 1
    3. Nikhil, breathing trouble    priority 2
    4. Ravi, high fever             priority 3
    5. Sunil, sprained ankle        priority 4
    6. Kiran, cut finger            priority 5
  Meera and Asha were both priority 1, and Meera arrived first,
  so she is seen first. That is what the arrival number buys.

MU's second example: job scheduling
      at  job                        priority  minutes
       0  payroll                           1       15
      15  send results by email             2        5
      20  clear the print queue             2        2
      22  rebuild the search index          4       25
      47  nightly backup                    5       40
      87  everything is done

a priority queue against the alternatives, for 5 jobs
  a sorted list  : each insert costs a search plus a shift
  an unsorted list: each next_item costs a scan of everything
  a heap          : both cost log n, which is why it is the answer

and what Python provides ready-made, with a trap in it
  heapq with just (priority, name): ['Asha', 'Meera', 'Nikhil', 'Ravi', 'Sunil', 'Kiran']
  Asha came out BEFORE Meera, and Meera arrived first. When the
  priorities tie, heapq compares the NEXT element of the tuple, so
  it broke the tie alphabetically: 'Asha' < 'Meera'.

  heapq with (priority, arrival, name): ['Meera', 'Asha', 'Nikhil', 'Ravi', 'Sunil', 'Kiran']
  now it matches the class above. The arrival number is not
  decoration: without it a priority queue is not stable, and with a
  payload that cannot be compared at all it raises TypeError.

  pushing a second object of equal priority: TypeError: '<' not supported between instances of 'Patient' and 'Patient'
munotes.in251

Practical 18 continued: Heaps and Priority Queues

Patient triage, and the arrival number

Six patients arrive in one order and are seen in another. Meera, chest pain, arrives second and is seen first; Kiran, cut finger, arrives fifth and is seen last. That is what a priority queue is.

The interesting case is the tie. Meera and Asha are both priority 1. They are seen Meera first, because she arrived first, and the reason is the middle element of the entry:

self.heap.insert((priority, next(self.arrivals), item))

The heap compares tuples, so it compares the priority first and the arrival number only when the priorities are equal. A priority queue that serves equal priorities in arrival order is called stable, and in a hospital that is not a nicety: two patients of the same urgency should be seen in the order they came in.

And the trap in Python's own heapq

The last part of the run is the proof that the arrival number matters, and it was a surprise. With entries of (priority, name) and no arrival number, heapq served Asha before Meera: the priorities tied, so it compared the next element of the tuple, and 'Asha' < 'Meera'. The queue was not stable, it was alphabetical, and nothing in the program said so.

munotes.in252

Practical 18 continued: Heaps and Priority Queues

Worse, the last two lines of the run show what happens with a payload that cannot be compared at all:

TypeError: '<' not supported between instances of 'Patient' and 'Patient'

Two objects of equal priority, and the heap tries to compare the objects themselves. Any class without a comparison method fails, and it fails only when a tie actually occurs, which may be long after the program was tested.

Always put a tie-breaker between the priority and the payload. An arrival counter is the

standard one, it makes the queue stable, and it means the payload is never compared.

Job scheduling

MU's second example, and it is the scheduler of [Practical 7: CPU Scheduling, FCFS and Non-preemptive Scheduling] from the data structure's side. Five jobs of four priorities, served best priority first, with the clock printed: payroll at 0, then the two priority-2 jobs in arrival order, then the index rebuild, then the backup.

The total is 15 + 5 + 2 + 25 + 40, which is 87 minutes, and the processor is never idle. This is non-preemptive priority scheduling, the third algorithm of Practical 7, and the priority queue is exactly what its ready queue should be: Practical 7's program searched the array for the best priority at every step, which is O(n) per decision, where a heap is O(log n).

Why not a sorted list

Implementationaddnext_item
Unsorted listO(1)O(n), scan for the best
Sorted listO(n), find the place and shiftO(1)
HeapO(log n)O(log n)
Balanced treeO(log n)O(log n), and it can also do everything else

The heap is the only one of the first three that is cheap at both, and it is much simpler than a balanced tree. That is why it is the standard answer.

The cost of each operation

OperationCostWhy
peekO(1)index 0
insertO(log n)sift up, one level per swap
extract_minO(log n)sift down
heapify from an arrayO(n)half the array is leaves
Build by inserting n timesO(n log n)which is why heapify exists
Find an arbitrary itemO(n)a heap has no search order
Delete an arbitrary itemO(n) to find, O(log n) to fix
HeapsortO(n log n) worst caseheapify plus n extractions
The k smallestO(n + k log n)heapify plus k extractions
SpaceO(n), an array and nothing elseno links at all

Procedure

  1. Write the min-heap and run it. Check the parent and child arithmetic for every index against
munotes.in253

Practical 18 continued: Heaps and Priority Queues

the printed levels.

  1. Insert 7 into the finished heap and follow it up the array by hand before running it.
  2. Change extract_min to promote the smaller child instead of moving the last leaf up, and run

is_heap() and the levels. The tree is no longer complete and the arithmetic breaks.

  1. Write the max-heap and heapsort, and confirm both orders against sorted().
  2. Write the in-place heapsort: build a max-heap in the array, then for i from n-1 down to 1, swap

a[0] with a[i] and sift down within the first i items. No second array at all.

  1. Write the priority queue and both of MU's examples.
  2. Remove the arrival number from the entry, run the triage again, and note which patient is now

seen first. Then try it with a payload class that has no comparison and read the TypeError.

Result

A min-heap was implemented in an array with the parent and child relations as arithmetic, insertion by sifting up and extraction by moving the last leaf to the root and sifting down, and the heap property was verified over the whole array after every operation. A heap was built from an unordered array in one pass, giving a different valid heap from the same items inserted one at a time. A max-heap was derived from the min-heap by reversing one comparison, heapsort was shown to agree with sorted() in both directions, and the four smallest of fifteen items were taken without sorting the rest. A stable priority queue was built over the heap and used for patient triage and job scheduling, and Python's own heapq was shown to break a priority tie alphabetically when no arrival number is supplied, and to raise TypeError when the payload cannot be compared.

Where marks are lost

  • Saying a heap is a binary search tree, or that a traversal of it is sorted. Neither is true.
  • Forgetting that a heap must be complete, which is the only reason the array arithmetic

works.

  • Promoting a child instead of moving the last leaf on extraction, which leaves a hole.
  • (i - 1) // 2 written as i // 2. That is right only for one-based indexing; say which you

are using.

  • Building a heap by inserting n times and calling it O(n). That is O(n log n); heapify from

an array is O(n).

  • A priority queue over a sorted list, and then saying both operations are cheap. One of them

is O(n).

  • No tie-breaker in the entry, so equal priorities come out in an order nobody chose, or the

payload is compared and raises.

  • Saying heapsort is faster than quicksort. It has the better worst case and is in place; it
munotes.in254

Practical 18 continued: Heaps and Priority Queues

is usually slower in practice.

  • Not printing the array. The array is the tree, and it is the working the examiner marks.

For the journal

Write the aim, MU's own wording, the definitions of min-heap, max-heap and complete tree, and the three index formulas. Then the array after each of the seven insertions, and the same array drawn as levels, because the two together are what show that the array is the tree. Add the table of parent and children for every index. Then the extractions in order, the heapify comparison showing two different valid heaps of the same items, and heapsort against sorted(). Then the triage table, arrivals against the order seen, with Meera and Asha's tie circled, and the job schedule with the clock. Close with the heapq trap, because it is the thing most likely to be worth a mark nobody else gets. The conclusion: a heap is a complete tree stored as an array with no links, it gives the best item in constant time and maintains itself in logarithmic time, and that is exactly the contract a priority queue needs.

Quick revision

  • A min-heap: every parent no larger than its children, so the smallest is at index 0. A max-heap

is the same with the comparison reversed.

  • A heap must be complete, which is what makes the array arithmetic work: parent

(i - 1) // 2, children 2i + 1 and 2i + 2.

  • A heap is not a search tree and no traversal of it is sorted. Finding an arbitrary item is

O(n).

  • insert: append, then sift up. extract: take index 0, move the last leaf to the root,

sift down. Both O(log n).

  • The last leaf moves because it is the only node that can go without breaking completeness.
  • heapify from an array is O(n), because half the array is leaves. Inserting n times is O(n

log n).

  • Two valid heaps of the same items need not be the same array.
  • Heapsort is O(n log n) in the worst case and in place, and slower than quicksort in

practice.

  • The k smallest of n costs O(n + k log n), which is the real use of a heap.
  • A priority queue's entry is (priority, arrival number, item). The arrival number makes it

stable and stops the payload from being compared.

  • heapq is a min-heap on a plain list. Without a tie-breaker it compares the next tuple element:

measured here, it served Asha before Meera and raised TypeError on an uncomparable payload.

  • A priority queue over an unsorted list is O(n) to serve; over a sorted list it is O(n) to add;
munotes.in255

Practical 18 continued: Heaps and Priority Queues

over a heap both are O(log n).

Questions you should be able to answer

1. What is the heap property, and how does a min-heap differ from a max-heap? Every parent stands in a fixed relation to both its children. In a min-heap the parent is no larger than either child, so the smallest item is at the root; in a max-heap it is no smaller, so the largest is.

2. Why must a heap be a complete binary tree? Because that is what allows it to be stored in an array with no gaps, so that the parent and child of any index can be computed rather than followed.

3. Give the three index formulas. For the item at index i: parent at (i - 1) // 2, left child at 2i + 1, right child at 2i + 2, with zero-based indexing.

4. Is a heap a binary search tree? No. The two children of a node are both larger than it in a min-heap, in no order relative to each other, so no traversal of a heap is sorted and finding an arbitrary item takes O(n).

5. On extraction, why is the last leaf moved to the root rather than a child promoted? Because the last leaf is the only node that can be removed without leaving a hole, so it is the only choice that keeps the tree complete and the array arithmetic valid.

6. What is the cost of building a heap from an unordered array, and why is it not O(n log n)? O(n). Sifting down from the last parent to the root does no work at all on the half of the array that is leaves, and the work on the rest halves at every level up, so the total is less than n.

7. Compare heapsort with quicksort. Both are O(n log n) on average. Heapsort is O(n log n) in the worst case and quicksort is O(n squared); heapsort is in place and quicksort needs O(log n) of stack. Quicksort is usually faster in practice because its memory access is kinder to the cache.

8. Why does a priority queue entry carry an arrival number? So that items of equal priority come out in the order they arrived, which makes the queue stable, and so that the payload itself is never compared. Without it, heapq compares the next element of the tuple: on this page it served Asha before Meera because 'Asha' sorts first, and it raised a TypeError on a payload with no comparison.

9. What is the cost of a priority queue over an unsorted list, a sorted list and a heap? Unsorted list: O(1) to add, O(n) to serve. Sorted list: O(n) to add, O(1) to serve. Heap: O(log n) for both, which is why it is the standard implementation.

munotes.in256

Practical 18 continued: Heaps and Priority Queues

10. What is the cheapest way to find the four smallest of fifteen items? Heapify the fifteen, which is O(n), then extract four times, which is O(k log n). Sorting all fifteen would be O(n log n) and would do work nobody asked for.

Contents This chapter on its own page

munotes.in257

Chapter Twenty-Eight

Practical 19: Graph Representations and Traversals

Syllabus topic Module 2, "Graph Representations and Traversals: Represent graphs using adjacency matrices and lists. Implement BFS and DFS to explore graph components. Use graphs for mapping routes or exploring social networks."

Aim

To represent a graph as an adjacency matrix and as an adjacency list, to insert and delete vertices and edges, to implement breadth-first and depth-first search, to find the components, and to use a graph for routes and for a social network.

What you need to know before you start

A graph is a set of vertices and a set of edges, each edge joining two vertices. That is all, and it is why graphs model so much: stations and lines, people and friendships, web pages and links, tasks and their prerequisites.

WordMeaning
Vertex, or nodeone of the things
Edgea connection between two vertices
Adjacent, or neighbourstwo vertices joined by an edge
Degree of a vertexhow many edges touch it
Patha sequence of vertices each joined to the next
Cyclea path that returns to where it started
Connectedthere is a path between every pair of vertices
Componenta maximal piece that is connected
Directedthe edges have a direction, so A to B is not B to A
Weightedthe edges carry a number, such as a distance

The graph in this chapter is undirected and unweighted, which is what MU's exercise asks for and what a railway map or a friendship network is.

A tree is a graph. A tree is exactly a connected graph with no cycle, which is why [Practical 17: Binary Search Trees and Tree Traversals] is a special case of this chapter and why level-order traversal turns out to be breadth-first search.

The handshaking lemma, which is a free check

In an undirected graph, the sum of all the degrees is twice the number of edges, because every edge contributes 1 to each of its two ends. The last lines of the third program check it: 9 friendships and the degrees add to 18.

That is the cheapest possible test on an adjacency list, and it belongs in the journal: if your degrees do not sum to twice your edges, you have added an edge in one direction only.

The two representations

from collections import deque


class GraphMatrix:
    """An adjacency MATRIX: a square grid of 0 and 1."""

    def __init__(self, labels):
        self.labels = list(labels)
        self.index = {name: i for i, name in enumerate(self.labels)}
        n = len(self.labels)
        self.m = [[0] * n for _ in range(n)]

    def add_edge(self, a, b):
        i, j = self.index[a], self.index[b]
        self.m[i][j] = self.m[j][i] = 1          # undirected: both cells

    def remove_edge(self, a, b):
        i, j = self.index[a], self.index[b]
        self.m[i][j] = self.m[j][i] = 0

    def has_edge(self, a, b):
        """ONE lookup, whatever the size of the graph."""
        return self.m[self.index[a]][self.index[b]] == 1

    def neighbours(self, a):
        i = self.index[a]
        return [self.labels[j] for j in range(len(self.labels)) if self.m[i][j]]

    def degree(self, a):
        return sum(self.m[self.index[a]])

    def cells(self):
        return len(self.labels) ** 2

    def show(self):
        head = "      " + " ".join(f"{x:>3}" for x in self.labels)
        print(head)
        for name, row in zip(self.labels, self.m):
            print(f"  {name:>3} " + " ".join(f"{v:>3}" for v in row))


class GraphList:
    """An adjacency LIST: for each vertex, the vertices it joins."""

    def __init__(self, labels=()):
        self.adj = {name: [] for name in labels}

    def add_vertex(self, a):
        self.adj.setdefault(a, [])

    def add_edge(self, a, b):
        self.add_vertex(a)
        self.add_vertex(b)
        if b not in self.adj[a]:
            self.adj[a].append(b)
            self.adj[b].append(a)

    def remove_edge(self, a, b):
        if b in self.adj.get(a, []):
            self.adj[a].remove(b)
            self.adj[b].remove(a)

    def remove_vertex(self, a):
        """A matrix cannot do this without rebuilding itself."""
        for other in self.adj.pop(a, []):
            self.adj[other].remove(a)

    def has_edge(self, a, b):
        """A SCAN of a's neighbours, so it costs the degree of a."""
        return b in self.adj.get(a, [])

    def neighbours(self, a):
        return list(self.adj.get(a, []))

    def degree(self, a):
        return len(self.adj.get(a, []))

    def entries(self):
        return sum(len(v) for v in self.adj.values())

    def show(self):
        for name in self.adj:
            print(f"  {name:>3} -> {', '.join(self.adj[name]) or '(none)'}")


# three-letter codes, so that the matrix fits on a page:
# CST Chhatrapati Shivaji Terminus, BYC Byculla, DDR Dadar, KUR Kurla,
# THN Thane, BAN Bandra, AND Andheri, BOR Borivali, PNV Panvel, VSH Vashi
STATIONS = ["CST", "BYC", "DDR", "KUR", "THN", "BAN",
            "AND", "BOR", "PNV", "VSH"]
EDGES = [("CST", "BYC"), ("BYC", "DDR"), ("DDR", "KUR"),
         ("KUR", "THN"), ("DDR", "BAN"), ("BAN", "AND"),
         ("AND", "BOR"), ("KUR", "AND"),
         ("PNV", "VSH")]

print("a graph of ten stations and nine lines between them")
print()
print("as an adjacency matrix, 1 where there is a line")
mat = GraphMatrix(STATIONS)
for a, b in EDGES:
    mat.add_edge(a, b)
mat.show()

print()
print("as an adjacency list")
lst = GraphList(STATIONS)
for a, b in EDGES:
    lst.add_edge(a, b)
lst.show()

print()
print("the same questions of both")
for a, b in (("DDR", "KUR"), ("CST", "THN")):
    print(f"  is there a line from {a} to {b}?  matrix {mat.has_edge(a, b)}"
          f"   list {lst.has_edge(a, b)}")
for a in ("DDR", "PNV"):
    print(f"  {a:<8} degree {mat.degree(a)}, neighbours {mat.neighbours(a)}")
print("  the two agree everywhere:",
      all(mat.neighbours(x) == sorted(lst.neighbours(x), key=STATIONS.index)
          for x in STATIONS))

print()
print("what each one costs to store, for this graph")
v, e = len(STATIONS), len(EDGES)
print(f"  vertices {v}, edges {e}")
print(f"  matrix: {v} x {v} = {mat.cells()} cells, and {2 * e} of them are 1")
print(f"  list  : {lst.entries()} entries, which is 2 per edge")
print(f"  the matrix is {mat.cells() / lst.entries():.1f} times the size here,")
print("  and this is a SPARSE graph: most pairs of stations have no line.")
munotes.in258

Practical 19: Graph Representations and Traversals

a graph of ten stations and nine lines between them

as an adjacency matrix, 1 where there is a line
      CST BYC DDR KUR THN BAN AND BOR PNV VSH
  CST   0   1   0   0   0   0   0   0   0   0
  BYC   1   0   1   0   0   0   0   0   0   0
  DDR   0   1   0   1   0   1   0   0   0   0
  KUR   0   0   1   0   1   0   1   0   0   0
  THN   0   0   0   1   0   0   0   0   0   0
  BAN   0   0   1   0   0   0   1   0   0   0
  AND   0   0   0   1   0   1   0   1   0   0
  BOR   0   0   0   0   0   0   1   0   0   0
  PNV   0   0   0   0   0   0   0   0   0   1
  VSH   0   0   0   0   0   0   0   0   1   0

as an adjacency list
  CST -> BYC
  BYC -> CST, DDR
  DDR -> BYC, KUR, BAN
  KUR -> DDR, THN, AND
  THN -> KUR
  BAN -> DDR, AND
  AND -> BAN, BOR, KUR
  BOR -> AND
  PNV -> VSH
  VSH -> PNV

the same questions of both
  is there a line from DDR to KUR?  matrix True   list True
  is there a line from CST to THN?  matrix False   list False
  DDR      degree 3, neighbours ['BYC', 'KUR', 'BAN']
  PNV      degree 1, neighbours ['VSH']
  the two agree everywhere: True

what each one costs to store, for this graph
  vertices 10, edges 9
  matrix: 10 x 10 = 100 cells, and 18 of them are 1
  list  : 18 entries, which is 2 per edge
  the matrix is 5.6 times the size here,
  and this is a SPARSE graph: most pairs of stations have no line.
munotes.in259

Practical 19: Graph Representations and Traversals

The adjacency matrix

A square grid with one row and one column per vertex, holding 1 where there is an edge.

For an undirected graph the matrix is symmetric: m[i][j] and m[j][i] are both set, which is the two lines in add_edge. A program that sets only one has built a directed graph by accident, and the symptom is that a route works in one direction and not the other.

Its strength is one question, answered instantly. "Is there an edge from A to B" is one array lookup, O(1), whatever the size of the graph.

Its weakness is everything else. It always occupies n squared cells whether they are used or not, listing a vertex's neighbours means scanning a whole row of n cells, and adding a vertex means building a bigger grid and copying.

The adjacency list

For each vertex, the list of vertices it joins. Here it is a dictionary from a name to a list of names, which is the usual Python form.

Its strength is that it stores only what exists. Nine edges give eighteen entries, two per edge, and listing a vertex's neighbours is reading its list. Adding a vertex is adding one empty list.

Its weakness is the one question the matrix answers instantly. "Is there an edge from A to B" is a scan of A's neighbours, so it costs the degree of A.

munotes.in260

Practical 19: Graph Representations and Traversals

The comparison, which is the answer to MU's first bullet

V is the number of vertices, E the number of edges.

Adjacency matrixAdjacency list
SpaceO(V squared), alwaysO(V + E)
Is there an edge A to BO(1)O(degree of A)
List A's neighboursO(V), a whole rowO(degree of A)
Add an edgeO(1)O(1)
Remove an edgeO(1)O(degree)
Add a vertexO(V squared), rebuildO(1)
Remove a vertexrebuild the gridO(degree)
Iterate over every edgeO(V squared)O(V + E)
Best fordense graphs, and edge testssparse graphs, which is nearly all of them

Nearly every real graph is sparse. Ten stations here have nine lines between them, not forty-five. The matrix needed 100 cells to hold 18 ones, which is 5.6 times the list's size, and that ratio grows with the graph: a million web pages with ten links each is 10 million list entries and a million million matrix cells.

The rule: matrix for a dense graph or when you ask about single edges constantly; list for everything else. A social network, a road map, a web crawl and a dependency graph are all lists.

Breadth first and depth first

The two traversals are the same program with a different container, and that is the single most useful thing to know about them.

from collections import deque


class GraphMatrix:
    """An adjacency MATRIX: a square grid of 0 and 1."""

    def __init__(self, labels):
        self.labels = list(labels)
        self.index = {name: i for i, name in enumerate(self.labels)}
        n = len(self.labels)
        self.m = [[0] * n for _ in range(n)]

    def add_edge(self, a, b):
        i, j = self.index[a], self.index[b]
        self.m[i][j] = self.m[j][i] = 1          # undirected: both cells

    def remove_edge(self, a, b):
        i, j = self.index[a], self.index[b]
        self.m[i][j] = self.m[j][i] = 0

    def has_edge(self, a, b):
        """ONE lookup, whatever the size of the graph."""
        return self.m[self.index[a]][self.index[b]] == 1

    def neighbours(self, a):
        i = self.index[a]
        return [self.labels[j] for j in range(len(self.labels)) if self.m[i][j]]

    def degree(self, a):
        return sum(self.m[self.index[a]])

    def cells(self):
        return len(self.labels) ** 2

    def show(self):
        head = "      " + " ".join(f"{x:>3}" for x in self.labels)
        print(head)
        for name, row in zip(self.labels, self.m):
            print(f"  {name:>3} " + " ".join(f"{v:>3}" for v in row))


class GraphList:
    """An adjacency LIST: for each vertex, the vertices it joins."""

    def __init__(self, labels=()):
        self.adj = {name: [] for name in labels}

    def add_vertex(self, a):
        self.adj.setdefault(a, [])

    def add_edge(self, a, b):
        self.add_vertex(a)
        self.add_vertex(b)
        if b not in self.adj[a]:
            self.adj[a].append(b)
            self.adj[b].append(a)

    def remove_edge(self, a, b):
        if b in self.adj.get(a, []):
            self.adj[a].remove(b)
            self.adj[b].remove(a)

    def remove_vertex(self, a):
        """A matrix cannot do this without rebuilding itself."""
        for other in self.adj.pop(a, []):
            self.adj[other].remove(a)

    def has_edge(self, a, b):
        """A SCAN of a's neighbours, so it costs the degree of a."""
        return b in self.adj.get(a, [])

    def neighbours(self, a):
        return list(self.adj.get(a, []))

    def degree(self, a):
        return len(self.adj.get(a, []))

    def entries(self):
        return sum(len(v) for v in self.adj.values())

    def show(self):
        for name in self.adj:
            print(f"  {name:>3} -> {', '.join(self.adj[name]) or '(none)'}")


# three-letter codes, so that the matrix fits on a page:
# CST Chhatrapati Shivaji Terminus, BYC Byculla, DDR Dadar, KUR Kurla,
# THN Thane, BAN Bandra, AND Andheri, BOR Borivali, PNV Panvel, VSH Vashi
STATIONS = ["CST", "BYC", "DDR", "KUR", "THN", "BAN",
            "AND", "BOR", "PNV", "VSH"]
EDGES = [("CST", "BYC"), ("BYC", "DDR"), ("DDR", "KUR"),
         ("KUR", "THN"), ("DDR", "BAN"), ("BAN", "AND"),
         ("AND", "BOR"), ("KUR", "AND"),
         ("PNV", "VSH")]

def bfs(g, start, trace=False):
    """Breadth first: a QUEUE. Visits everything at distance 1, then 2, ..."""
    seen = {start}
    order = []
    q = deque([start])
    if trace:
        print(f"    {'take':<6}{'new neighbours':<22}queue after")
    while q:
        here = q.popleft()
        order.append(here)
        added = []
        for n in g.neighbours(here):
            if n not in seen:
                seen.add(n)
                q.append(n)
                added.append(n)
        if trace:
            print(f"    {here:<6}{', '.join(added) or '-':<22}{list(q)}")
    return order


def dfs_iterative(g, start, trace=False):
    """Depth first: the same code with a STACK instead of a queue."""
    seen = {start}
    order = []
    stack = [start]
    if trace:
        print(f"    {'take':<6}{'new neighbours':<22}stack after")
    while stack:
        here = stack.pop()
        order.append(here)
        added = []
        for n in g.neighbours(here):
            if n not in seen:
                seen.add(n)
                stack.append(n)
                added.append(n)
        if trace:
            print(f"    {here:<6}{', '.join(added) or '-':<22}{stack}")
    return order


def dfs_recursive(g, start, seen=None, order=None):
    if seen is None:
        seen, order = {start}, []
    order.append(start)
    for n in g.neighbours(start):
        if n not in seen:
            seen.add(n)
            dfs_recursive(g, n, seen, order)
    return order


def components(g, labels):
    """Every part of the graph that is joined up."""
    seen, out = set(), []
    for v in labels:
        if v not in seen:
            part = bfs(g, v)
            seen.update(part)
            out.append(part)
    return out


def shortest_path(g, start, goal):
    """BFS again, remembering where each vertex was reached FROM.
    On an unweighted graph this gives the fewest hops."""
    if start == goal:
        return [start]
    came_from = {start: None}
    q = deque([start])
    while q:
        here = q.popleft()
        for n in g.neighbours(here):
            if n in came_from:
                continue
            came_from[n] = here
            if n == goal:
                path = [goal]
                while came_from[path[-1]] is not None:
                    path.append(came_from[path[-1]])
                return list(reversed(path))
            q.append(n)
    return None


g = GraphList(STATIONS)
for a, b in EDGES:
    g.add_edge(a, b)

print("breadth first from CST, step by step")
order = bfs(g, "CST", trace=True)
print("  visited:", order)

print()
print("depth first from CST, the SAME code with a stack")
order2 = dfs_iterative(g, "CST", trace=True)
print("  visited:", order2)

print()
print("depth first again, recursively:", dfs_recursive(g, "CST"))
print("  the recursive and iterative walks differ, and both are correct DFS:")
print("  the stack reverses the order the neighbours are pushed in.")

print()
print("the graph is not all joined up")
for part in components(g, STATIONS):
    print("  a component:", part)
print("  two components, so no route exists between them at all")

print()
print("fewest hops, which is what BFS gives on an unweighted graph")
for a, b in (("CST", "BOR"), ("CST", "THN"), ("BYC", "AND"), ("CST", "PNV")):
    p = shortest_path(g, a, b)
    if p is None:
        print(f"  {a} to {b}: no route")
    else:
        print(f"  {a} to {b}: {' -> '.join(p)}  ({len(p) - 1} hops)")

print()
print("and why DFS is the wrong tool for that question")
path = dfs_recursive(g, "CST")
print("  a depth-first walk from CST visits", path)
print("  BOR is reached, but not by the shortest route: DFS follows one branch")
print("  to its end before trying another, so the path it finds may be long.")
munotes.in261

Practical 19: Graph Representations and Traversals

breadth first from CST, step by step
    take  new neighbours        queue after
    CST   BYC                   ['BYC']
    BYC   DDR                   ['DDR']
    DDR   KUR, BAN              ['KUR', 'BAN']
    KUR   THN, AND              ['BAN', 'THN', 'AND']
    BAN   -                     ['THN', 'AND']
    THN   -                     ['AND']
    AND   BOR                   ['BOR']
    BOR   -                     []
  visited: ['CST', 'BYC', 'DDR', 'KUR', 'BAN', 'THN', 'AND', 'BOR']

depth first from CST, the SAME code with a stack
    take  new neighbours        stack after
    CST   BYC                   ['BYC']
    BYC   DDR                   ['DDR']
    DDR   KUR, BAN              ['KUR', 'BAN']
    BAN   AND                   ['KUR', 'AND']
    AND   BOR                   ['KUR', 'BOR']
    BOR   -                     ['KUR']
    KUR   THN                   ['THN']
    THN   -                     []
  visited: ['CST', 'BYC', 'DDR', 'BAN', 'AND', 'BOR', 'KUR', 'THN']

depth first again, recursively: ['CST', 'BYC', 'DDR', 'KUR', 'THN', 'AND', 'BAN', 'BOR']
  the recursive and iterative walks differ, and both are correct DFS:
  the stack reverses the order the neighbours are pushed in.

the graph is not all joined up
  a component: ['CST', 'BYC', 'DDR', 'KUR', 'BAN', 'THN', 'AND', 'BOR']
  a component: ['PNV', 'VSH']
  two components, so no route exists between them at all

fewest hops, which is what BFS gives on an unweighted graph
  CST to BOR: CST -> BYC -> DDR -> KUR -> AND -> BOR  (5 hops)
  CST to THN: CST -> BYC -> DDR -> KUR -> THN  (4 hops)
  BYC to AND: BYC -> DDR -> KUR -> AND  (3 hops)
  CST to PNV: no route

and why DFS is the wrong tool for that question
  a depth-first walk from CST visits ['CST', 'BYC', 'DDR', 'KUR', 'THN', 'AND', 'BAN', 'BOR']
  BOR is reached, but not by the shortest route: DFS follows one branch
  to its end before trying another, so the path it finds may be long.
munotes.in262

Practical 19: Graph Representations and Traversals

The difference is the queue against the stack

Put the two loops side by side:

here = q.popleft()      # breadth first: a QUEUE, first in first out
here = stack.pop()      # depth first:   a STACK, last in first out
munotes.in263

Practical 19: Graph Representations and Traversals

Everything else is identical. And the behaviour that follows is exactly what the two disciplines mean:

  • Breadth first takes the oldest waiting vertex, so it finishes everything one hop away before

it looks at anything two hops away. It spreads out in rings.

  • Depth first takes the newest, so it follows one branch as far as it goes before coming back.

It plunges.

Read the two traces. BFS from CST visits CST, BYC, DDR, then KUR and BAN together, which are both three hops out. DFS visits CST, BYC, DDR, then dives BAN, AND, BOR to the end of that branch, and only then comes back for KUR and THN.

Breadth firstDepth first
Containerqueuestack, or recursion
Visitsnearest first, in ringsone branch to its end
Finds the shortest pathyes, on an unweighted graphno
Memorythe whole frontier, which can be widethe current path, which is at most V
Good forfewest hops, levels, nearest anythingcycles, connectivity, topological order, mazes
In a treelevel-order traversalpre-order traversal

Three things the run shows

The recursive and iterative depth-first walks differ. Recursive gives CST, BYC, DDR, KUR, THN, AND, BAN, BOR; the stack version gives CST, BYC, DDR, BAN, AND, BOR, KUR, THN. Both are correct depth-first traversals, and the reason they differ is that a stack reverses the order the neighbours were pushed in, so the stack version takes the last neighbour first. An examiner asking for "the" DFS order should say which; if not, say which convention you used.

seen is checked when a vertex is pushed, not when it is taken. That is why no vertex appears twice in the queue or the stack. Marking on removal instead lets a vertex be queued several times before it is first taken, which still gives the right answer and wastes memory; on a graph with cycles, forgetting to mark at all never terminates.

The graph has two components. CST's component has eight stations and Panvel and Vashi are a separate pair, so there is no route between them at all. Finding the components is BFS from every vertex not yet seen, which is what components does, and it is the standard way to answer "is this graph connected".

The shortest path, and why it has to be BFS

shortest_path is BFS with one addition: a dictionary remembering which vertex each one was reached from. When the goal is reached, following those back gives the path.

It gives the fewest hops, and the reason is the ring behaviour: BFS reaches every vertex at distance 1 before any at distance 2, so the first time it reaches the goal it has come by a shortest route. Depth-first search has no such property, and the last lines of the run make the point: a depth-first walk from CST does reach BOR, but by whatever branch it happened to dive down.

munotes.in264

Practical 19: Graph Representations and Traversals

This is only true on an unweighted graph. Add distances to the edges and the fewest hops is no longer the shortest journey, and the answer becomes Dijkstra's algorithm, which is BFS with a priority queue instead of a plain queue, taking the nearest unvisited vertex rather than the oldest. That is the priority queue of [Practical 18 continued: Heaps and Priority Queues], and the connection is worth stating: Dijkstra is BFS with the heap swapped in for the queue, exactly as DFS is BFS with a stack swapped in.

MU's application: a social network

MU names route mapping and social networks; the routes are above and this is the other.

from collections import deque


class GraphMatrix:
    """An adjacency MATRIX: a square grid of 0 and 1."""

    def __init__(self, labels):
        self.labels = list(labels)
        self.index = {name: i for i, name in enumerate(self.labels)}
        n = len(self.labels)
        self.m = [[0] * n for _ in range(n)]

    def add_edge(self, a, b):
        i, j = self.index[a], self.index[b]
        self.m[i][j] = self.m[j][i] = 1          # undirected: both cells

    def remove_edge(self, a, b):
        i, j = self.index[a], self.index[b]
        self.m[i][j] = self.m[j][i] = 0

    def has_edge(self, a, b):
        """ONE lookup, whatever the size of the graph."""
        return self.m[self.index[a]][self.index[b]] == 1

    def neighbours(self, a):
        i = self.index[a]
        return [self.labels[j] for j in range(len(self.labels)) if self.m[i][j]]

    def degree(self, a):
        return sum(self.m[self.index[a]])

    def cells(self):
        return len(self.labels) ** 2

    def show(self):
        head = "      " + " ".join(f"{x:>3}" for x in self.labels)
        print(head)
        for name, row in zip(self.labels, self.m):
            print(f"  {name:>3} " + " ".join(f"{v:>3}" for v in row))


class GraphList:
    """An adjacency LIST: for each vertex, the vertices it joins."""

    def __init__(self, labels=()):
        self.adj = {name: [] for name in labels}

    def add_vertex(self, a):
        self.adj.setdefault(a, [])

    def add_edge(self, a, b):
        self.add_vertex(a)
        self.add_vertex(b)
        if b not in self.adj[a]:
            self.adj[a].append(b)
            self.adj[b].append(a)

    def remove_edge(self, a, b):
        if b in self.adj.get(a, []):
            self.adj[a].remove(b)
            self.adj[b].remove(a)

    def remove_vertex(self, a):
        """A matrix cannot do this without rebuilding itself."""
        for other in self.adj.pop(a, []):
            self.adj[other].remove(a)

    def has_edge(self, a, b):
        """A SCAN of a's neighbours, so it costs the degree of a."""
        return b in self.adj.get(a, [])

    def neighbours(self, a):
        return list(self.adj.get(a, []))

    def degree(self, a):
        return len(self.adj.get(a, []))

    def entries(self):
        return sum(len(v) for v in self.adj.values())

    def show(self):
        for name in self.adj:
            print(f"  {name:>3} -> {', '.join(self.adj[name]) or '(none)'}")


# three-letter codes, so that the matrix fits on a page:
# CST Chhatrapati Shivaji Terminus, BYC Byculla, DDR Dadar, KUR Kurla,
# THN Thane, BAN Bandra, AND Andheri, BOR Borivali, PNV Panvel, VSH Vashi
STATIONS = ["CST", "BYC", "DDR", "KUR", "THN", "BAN",
            "AND", "BOR", "PNV", "VSH"]
FRIENDSHIPS = [("Aarti", "Bhavesh"), ("Aarti", "Chetna"),
               ("Bhavesh", "Devang"), ("Chetna", "Devang"),
               ("Chetna", "Esha"), ("Devang", "Farhan"),
               ("Esha", "Farhan"), ("Farhan", "Gauri"),
               ("Hemant", "Ishita")]
PEOPLE = ["Aarti", "Bhavesh", "Chetna", "Devang", "Esha",
          "Farhan", "Gauri", "Hemant", "Ishita"]


def distances(g, start):
    """Every vertex, and how many hops away it is. BFS, counting levels."""
    far = {start: 0}
    q = deque([start])
    while q:
        here = q.popleft()
        for n in g.neighbours(here):
            if n not in far:
                far[n] = far[here] + 1
                q.append(n)
    return far


net = GraphList(PEOPLE)
for a, b in FRIENDSHIPS:
    net.add_edge(a, b)

print("a small social network")
net.show()

print()
me = "Aarti"
far = distances(net, me)
print(f"how far everybody is from {me}")
by_level = {}
for who, d in far.items():
    by_level.setdefault(d, []).append(who)
for d in sorted(by_level):
    label = {0: "herself", 1: "friends", 2: "friends of friends"}.get(
        d, f"{d} hops away")
    print(f"  {d} hop(s), {label:<20}{', '.join(sorted(by_level[d]))}")
unreachable = [p for p in PEOPLE if p not in far]
print(f"  not connected at all         {', '.join(unreachable)}")

print()
print(f"people to suggest to {me}: at distance 2, so friends of friends")
print("  and not already friends")
suggestions = sorted(w for w, d in far.items() if d == 2)
print("  suggestions:", suggestions)
for who in suggestions:
    shared = sorted(set(net.neighbours(who)) & set(net.neighbours(me)))
    print(f"    {who:<9} through {', '.join(shared)}")

print()
print("mutual friends of any two people")
for a, b in (("Aarti", "Devang"), ("Bhavesh", "Chetna"), ("Aarti", "Gauri")):
    shared = sorted(set(net.neighbours(a)) & set(net.neighbours(b)))
    print(f"  {a} and {b}: {', '.join(shared) or 'none'}")

print()
print("the degree of each person, which is how many friends they have")
for who in sorted(PEOPLE, key=lambda p: (-net.degree(p), p)):
    print(f"  {who:<9}{net.degree(who)}")
print("  the sum of all the degrees is", sum(net.degree(p) for p in PEOPLE),
      "which is twice the", len(FRIENDSHIPS), "friendships.")
print("  That is the handshaking lemma, and it is a useful check on any")
print("  adjacency list: the entries must come to twice the edges.")
munotes.in265

Practical 19: Graph Representations and Traversals

a small social network
  Aarti -> Bhavesh, Chetna
  Bhavesh -> Aarti, Devang
  Chetna -> Aarti, Devang, Esha
  Devang -> Bhavesh, Chetna, Farhan
  Esha -> Chetna, Farhan
  Farhan -> Devang, Esha, Gauri
  Gauri -> Farhan
  Hemant -> Ishita
  Ishita -> Hemant

how far everybody is from Aarti
  0 hop(s), herself             Aarti
  1 hop(s), friends             Bhavesh, Chetna
  2 hop(s), friends of friends  Devang, Esha
  3 hop(s), 3 hops away         Farhan
  4 hop(s), 4 hops away         Gauri
  not connected at all         Hemant, Ishita

people to suggest to Aarti: at distance 2, so friends of friends
  and not already friends
  suggestions: ['Devang', 'Esha']
    Devang    through Bhavesh, Chetna
    Esha      through Chetna

mutual friends of any two people
  Aarti and Devang: Bhavesh, Chetna
  Bhavesh and Chetna: Aarti, Devang
  Aarti and Gauri: none

the degree of each person, which is how many friends they have
  Chetna   3
  Devang   3
  Farhan   3
  Aarti    2
  Bhavesh  2
  Esha     2
  Gauri    1
  Hemant   1
  Ishita   1
  the sum of all the degrees is 18 which is twice the 9 friendships.
  That is the handshaking lemma, and it is a useful check on any
  adjacency list: the entries must come to twice the edges.
munotes.in266

Practical 19: Graph Representations and Traversals

Distance is the whole idea

A breadth-first search from one person, counting levels, gives everything a social network shows you:

DistanceWhat it meansHere, from Aarti
0yourselfAarti
1your friendsBhavesh, Chetna
2friends of friends, the suggestionsDevang, Esha
3 and morethe rest of your componentFarhan, then Gauri
unreachablea different componentHemant, Ishita

People to suggest are exactly those at distance 2. Distance 1 is already a friend, and distance 3 is too far to be interesting; distance 2 means you have a friend in common and are not yet connected, which is precisely what "people you may know" means. And the program says through whom, by intersecting the two neighbour sets, which is what a real network shows beside the suggestion.

Mutual friends are a set intersection, one line, because an adjacency list gives a vertex's neighbours directly. That operation is why a social network is stored as an adjacency list and not as a matrix: a matrix would need a scan of two whole rows.

Hemant and Ishita are unreachable. They are a separate component, so no number of hops connects them to Aarti. A network with several components is the normal state of affairs, and a program that assumes the graph is connected reports a distance of infinity as a bug.

And the handshaking lemma checks the data. Nine friendships, degrees summing to 18. If a friendship had been added in one direction only the sum would be odd, which is impossible in an undirected graph, and the check would catch it.

Where graphs are used

ProblemThe graphThe algorithm
Fewest stations between two stopsstations and linesBFS
Shortest journey with distancesstations and lines with lengthsDijkstra, BFS with a heap
People you may knowpeople and friendshipsBFS to distance 2
Is this network all joined upanythingBFS or DFS from every unseen vertex
In what order can these tasks runtasks and prerequisitesDFS, topological sort
Is there a cycle in this dependencymodules and importsDFS
Solving a mazecells and openingsDFS to find any route, BFS for the shortest
Web crawlingpages and linksBFS, so that nearby pages come first
Garbage collectionobjects and referencestraversal from the roots
munotes.in267

Practical 19: Graph Representations and Traversals

The last row is worth a moment: a garbage collector is a graph traversal over the objects in memory, starting from the variables in scope, and anything not reached is freed. Every program a student writes is already using one.

The cost of the traversals

V vertices, E edges, on an adjacency list.

OperationCostWhy
BFS or DFS, whole graphO(V + E)every vertex once, every edge twice
The same on a matrixO(V squared)every row must be scanned
Shortest path, unweightedO(V + E), BFS
Shortest path, weightedO((V + E) log V), Dijkstrathe heap
ComponentsO(V + E)one traversal in total, not one per vertex
Memory, BFSO(V), the widest frontier
Memory, DFSO(V), the deepest pathrecursion uses the call stack for it

O(V + E) is the figure to remember, and it is the reason the adjacency list wins: the same traversal on a matrix is O(V squared), which for a sparse graph is enormously worse.

Procedure

  1. Write both representations and run the program. Check three cells of the matrix against three

lines of the adjacency list by hand.

  1. Set only m[i][j] in add_edge and confirm that the matrix is no longer symmetric and that a

route works one way only.

  1. Add a station to the list in O(1). Then work out what adding one to the matrix would take.
  2. Write the traversals. Follow both traces on paper and confirm the ring against the dive.
  3. Swap q.popleft() for q.pop() in the BFS and confirm you have written a DFS.
  4. Remove the if n not in seen test and run it on the graph, which has cycles. The program never

ends; stop it with Ctrl+C.

  1. Write the social network. Add a friendship between Aarti and Hemant and confirm the two

components become one and that everybody now has a distance.

Result

A graph of ten vertices and nine edges was represented as an adjacency matrix and as an adjacency list, and the two were shown to give the same neighbours for every vertex. The matrix occupied 100 cells to hold 18 ones, 5.6 times the list's 18 entries, on a graph that is sparse. Breadth-first and depth-first search were implemented as the same loop with a queue and with a stack, and their traces printed: breadth first visited the vertices in rings and depth first followed one branch to its end. The graph was found to have two components, so that some pairs have no route. Shortest paths by fewest hops were produced by breadth-first search with a record of where each vertex was reached from. The same structure was used as a social network, where the people to suggest were exactly those at distance 2, mutual friends were a set intersection, and the degrees were checked to sum to twice the number of friendships.

munotes.in268

Practical 19: Graph Representations and Traversals

Where marks are lost

  • Setting only one cell of the matrix for an undirected edge. The matrix must be symmetric.
  • Saying the matrix is better because a lookup is O(1) without saying it costs O(V squared) in

space. Both halves are the answer.

  • BFS with a stack, or DFS with a queue. The container is the definition.
  • Not marking a vertex as seen, which never terminates on a graph with a cycle.
  • Marking on removal instead of on insertion, which queues vertices several times.
  • Claiming DFS finds the shortest path. Only BFS does, and only on an unweighted graph.
  • Assuming the graph is connected. Find the components, or say that you assumed it.
  • Using BFS on a weighted graph and calling the answer the shortest path. That needs Dijkstra.
  • Not printing the queue or the stack. It is the working, and it is what distinguishes the two

algorithms on paper.

  • Forgetting that a vertex's own list must not contain itself unless the graph really has a

self-loop.

For the journal

Write the aim, MU's own wording, and the graph drawn as a picture with its ten vertices and nine edges. Then the matrix and the adjacency list of that same graph, side by side, with the cell count against the entry count. Then both traversals with their step tables showing the queue and the stack, because those two tables are what an examiner marks, and one sentence saying that the only difference in the program is which end the next vertex comes from. Then the components, the shortest paths, and the social network with the levels from one person and the suggestions at distance 2. Close with the handshaking check: degrees 18, edges 9. The conclusion: a graph is vertices and edges, an adjacency list costs O(V + E) where a matrix costs O(V squared), and breadth first and depth first are one algorithm with a queue or a stack in it.

Quick revision

  • A graph is vertices and edges. Degree is how many edges touch a vertex. A component is a piece

that is joined up.

  • Handshaking lemma: the degrees sum to twice the number of edges. Use it to check your data.
  • Adjacency matrix: O(V squared) space always, O(1) edge test, O(V) to list neighbours, symmetric

for an undirected graph.

  • Adjacency list: O(V + E) space, O(degree) edge test, O(degree) to list neighbours, O(1) to add a

vertex. Right for nearly every real graph, because nearly every real graph is sparse.

  • BFS uses a queue and visits in rings. DFS uses a stack, or recursion, and follows one
munotes.in269

Practical 19: Graph Representations and Traversals

branch to its end. They are the same program otherwise.

  • Mark a vertex as seen when it is pushed, not when it is taken. Without marking at all, a

graph with a cycle never terminates.

  • BFS finds the path with the fewest hops, because it reaches everything at distance k before

anything at k + 1. DFS does not.

  • On a weighted graph the shortest path needs Dijkstra, which is BFS with a priority queue

instead of a queue.

  • Both traversals are O(V + E) on a list and O(V squared) on a matrix.
  • Components: BFS or DFS from every vertex not yet seen. One traversal in total, O(V + E).
  • A social network's suggestions are the vertices at distance 2; mutual friends are a set

intersection of two neighbour lists.

  • A tree is a connected graph with no cycle. Level-order is BFS and pre-order is DFS.

Questions you should be able to answer

1. Give the space cost of each representation and say which suits a sparse graph. A matrix is O(V squared) whatever the number of edges; a list is O(V + E). A sparse graph, which is nearly every real one, suits the list. Here ten vertices and nine edges needed 100 matrix cells against 18 list entries.

2. What must be true of an adjacency matrix for an undirected graph? It must be symmetric: m[i][j] and m[j][i] are both set for every edge.

3. What is the only difference between BFS and DFS in a program? Whether the next vertex is taken from the front of a queue or the top of a stack. Everything else is identical.

4. Which finds the shortest path, and under what condition? Breadth-first search, and only on an unweighted graph. It reaches everything at distance k before anything at distance k + 1, so the first time it reaches the goal it has come the shortest way.

5. Why must a vertex be marked as seen when it is pushed rather than when it is taken? Because otherwise it can be pushed several times before it is first taken, which wastes memory. Without marking at all, a graph with a cycle is traversed for ever.

6. What is a component, and how are the components found? A maximal part of the graph that is joined up. Run a traversal from every vertex that has not yet been seen; each run is one component. In total it costs O(V + E).

7. What is the cost of a full traversal on each representation? O(V + E) on an adjacency list and O(V squared) on a matrix, because the matrix makes you scan a whole row per vertex.

munotes.in270

Practical 19: Graph Representations and Traversals

8. In a social network, which people should be suggested to somebody, and why? Those at distance 2: they have a friend in common and are not already friends. Distance 1 is already a friend and distance 3 has nothing in common.

9. State the handshaking lemma and say what it is good for. The sum of all the degrees is twice the number of edges, because every edge adds 1 to each of its ends. It is a free check on an adjacency list: an odd sum means an edge was added in one direction only.

10. What is the relation between BFS, DFS and Dijkstra's algorithm? All three are the same traversal with a different container: a queue gives breadth first, a stack gives depth first, and a priority queue keyed on distance gives Dijkstra, which is the shortest path on a weighted graph.

Contents This chapter on its own page

munotes.in271

Chapter Twenty-Nine

Practical 20: Hashing and Collision Handling

Syllabus topic Module 2, "Hashing Concepts and Collision Handling: Implement a hash table with chaining or linear probing. Simulate insertion, deletion, and search with collisions. Discuss practical hashing applications (e.g., dictionary lookup, indexing)."

Aim

To implement a hash table with separate chaining and again with linear probing, to insert, search and delete with collisions happening, and to see what the load factor and the hash function do.

What you need to know before you start

Every structure so far finds a key by comparing it with the keys already stored: a linked list walks, a tree compares at each level. Hashing does not compare at all. It computes, from the key itself, the place where the key belongs.

hash function: a function that turns a key into an index in a fixed range.

Given a table of n slots and a key k, the index is hash(k) % n. To store, compute the index and put the item there. To find, compute the index and look. One step, whatever the size of the table.

That is why a hash table is O(1) where a tree is O(log n), and it is why Python's own dictionary, which every program in this book uses, is a hash table.

Hash tableBalanced treeSorted array
Find one keyO(1) averageO(log n)O(log n)
InsertO(1) averageO(log n)O(n)
DeleteO(1) averageO(log n)O(n)
Worst caseO(n)O(log n)O(log n)
Keys in ordernoyesyes
Smallest keyO(n)O(log n)O(1)
Range querynoyesyes
Extra space30 to 100 per cent spareone or two links a nodenone

The three "no" rows are the price, and they are the reason trees survive. A hash table is the right answer when you look keys up one at a time and never want them in order.

The collision, which is the whole subject

Two different keys can hash to the same index. That is not a defect to be engineered away; it is certain. With 365 slots and 23 keys the chance of a collision is already better than even, which is the birthday problem, and a table of n slots holding more than n keys must have collisions by counting alone.

So a hash table is not really "a hash function". It is a hash function and a rule for what to do when two keys land together, and MU names the two standard rules:

RuleAlso calledWhat a slot holdsWhere the second key goes
Separate chainingopen hashinga list of itemson the end of that list
Linear probingopen addressing, closed hashingone itemthe next free slot along

The old names are confusingly similar: open hashing is chaining and open addressing is probing. Use the modern names.

What makes a good hash function

Three properties, and a student should be able to say all three:

munotes.in272

Practical 20: Hashing and Collision Handling

  1. Deterministic. The same key must always give the same index, or nothing can be found again.
  2. Uniform. The indices should be spread evenly over the table, or the collisions pile into a

few slots.

  1. Fast. The whole point is one step; a slow hash function throws it away.

And the standard method for an integer key is key % n with n prime. A prime modulus matters: with n = 100 and keys that are all multiples of 10, only ten slots are ever used. With n = 101 every slot is reachable. The tables in this chapter use 7 and 11 for that reason.

Separate chaining

class ChainedHashTable:
    """Separate chaining: every bucket holds a LIST of (key, value) pairs."""

    def __init__(self, buckets=7):
        self.n = buckets
        self.table = [[] for _ in range(buckets)]
        self.count = 0
        self.collisions = 0            # how many times a bucket was not empty

    def hash(self, key):
        """A deliberately simple hash: the sum of the letters, modulo n.
        A real one is better; this one lets collisions be seen by hand."""
        total = sum(ord(c) for c in str(key))
        return total % self.n

    def __len__(self):
        return self.count

    def load_factor(self):
        return self.count / self.n

    def put(self, key, value):
        b = self.hash(key)
        bucket = self.table[b]
        if bucket:
            self.collisions += 1
        for i, (k, _) in enumerate(bucket):
            if k == key:
                bucket[i] = (key, value)       # an update, not an insertion
                return b
        bucket.append((key, value))
        self.count += 1
        return b

    def get(self, key):
        b = self.hash(key)
        for k, v in self.table[b]:
            if k == key:
                return v
        raise KeyError(f"{key!r} is not in the table")

    def delete(self, key):
        b = self.hash(key)
        bucket = self.table[b]
        for i, (k, v) in enumerate(bucket):
            if k == key:
                del bucket[i]                  # no tombstone needed
                self.count -= 1
                return v
        raise KeyError(f"{key!r} is not in the table")

    def __contains__(self, key):
        try:
            self.get(key)
            return True
        except KeyError:
            return False

    def probes_to_find(self, key):
        """How many comparisons a lookup costs: the position in its chain."""
        b = self.hash(key)
        for i, (k, _) in enumerate(self.table[b], start=1):
            if k == key:
                return i
        return len(self.table[b]) + 1

    def show(self):
        for b, bucket in enumerate(self.table):
            items = " -> ".join(f"{k}:{v}" for k, v in bucket)
            print(f"    {b}  {items or '(empty)'}")
        print(f"    {len(self)} item(s) in {self.n} buckets, "
              f"load factor {self.load_factor():.2f}, "
              f"{self.collisions} collision(s) on insertion")


subjects = [("Maths", 78), ("Physics", 65), ("Chemistry", 92),
            ("Biology", 71), ("English", 54), ("Marathi", 66),
            ("History", 83)]

h = ChainedHashTable(7)
print("the hash of each key, before anything is stored")
print(f"    {'key':<12}{'sum of letters':>16}{'modulo 7':>10}")
for key, _ in subjects:
    total = sum(ord(c) for c in key)
    print(f"    {key:<12}{total:>16}{total % 7:>10}")

print()
print("storing them")
for key, mark in subjects:
    b = h.put(key, mark)
    print(f"    put {key:<12} -> bucket {b}")

print()
print("the table")
h.show()

print()
print("looking them up, and what each costs")
print(f"    {'key':<12}{'value':>7}{'comparisons':>14}")
for key, _ in subjects:
    print(f"    {key:<12}{h.get(key):>7}{h.probes_to_find(key):>14}")

print()
print("a key that is not there")
try:
    h.get("Sanskrit")
except KeyError as e:
    print("    get('Sanskrit'): KeyError:", e)
print("    'Sanskrit' in h:", "Sanskrit" in h)

print()
print("updating and deleting")
h.put("Maths", 88)
print("    put Maths again  ->", h.get("Maths"), "and the count is still", len(h))
print("    delete Physics   ->", h.delete("Physics"))
h.show()
try:
    h.delete("Physics")
except KeyError as e:
    print("    delete it again: KeyError:", e)
munotes.in273

Practical 20: Hashing and Collision Handling

the hash of each key, before anything is stored
    key           sum of letters  modulo 7
    Maths                    509         5
    Physics                  739         4
    Chemistry                952         0
    Biology                  725         4
    English                  714         0
    Marathi                  710         3
    History                  754         5

storing them
    put Maths        -> bucket 5
    put Physics      -> bucket 4
    put Chemistry    -> bucket 0
    put Biology      -> bucket 4
    put English      -> bucket 0
    put Marathi      -> bucket 3
    put History      -> bucket 5

the table
    0  Chemistry:92 -> English:54
    1  (empty)
    2  (empty)
    3  Marathi:66
    4  Physics:65 -> Biology:71
    5  Maths:78 -> History:83
    6  (empty)
    7 item(s) in 7 buckets, load factor 1.00, 3 collision(s) on insertion

looking them up, and what each costs
    key           value   comparisons
    Maths            78             1
    Physics          65             1
    Chemistry        92             1
    Biology          71             2
    English          54             2
    Marathi          66             1
    History          83             2

a key that is not there
    get('Sanskrit'): KeyError: "'Sanskrit' is not in the table"
    'Sanskrit' in h: False

updating and deleting
    put Maths again  -> 88 and the count is still 7
    delete Physics   -> 65
    0  Chemistry:92 -> English:54
    1  (empty)
    2  (empty)
    3  Marathi:66
    4  Biology:71
    5  Maths:88 -> History:83
    6  (empty)
    6 item(s) in 7 buckets, load factor 0.86, 4 collision(s) on insertion
    delete it again: KeyError: "'Physics' is not in the table"

Reading the run

The hash is worked out in the open first. Each subject name's letters are added up and the total taken modulo 7, and the table is printed so the arithmetic can be checked by hand. That is what MU's "simulate" means: an examiner wants the hash of each key written down.

Three collisions out of seven insertions. Chemistry and English both went to bucket 0, Physics and Biology to bucket 4, Maths and History to bucket 5. Three of the seven buckets are empty and three hold two items each, which is exactly the uneven spread that a hash of "add up the letters" produces.

The cost of a lookup is the position in the chain. The comparison count printed beside each key is 1 for the first item in its bucket and 2 for the second. A bucket holding k items costs up to k comparisons, so the whole performance question is how long the chains get.

munotes.in274

Practical 20: Hashing and Collision Handling

Deletion is trivial. Remove the pair from the list, and that is all. Compare the next section: this simplicity is chaining's main practical advantage.

An update is not an insertion. put("Maths", 88) found the existing key and replaced the value, and len(h) stayed at 7. A table that appends a second pair with the same key has two entries that can never both be found, and the symptom is that a delete appears not to work.

The load factor

load factor = number of items divided by number of buckets

With chaining, the load factor is the average chain length, and there is nothing to stop it exceeding 1: seven items in seven buckets is a load factor of 1.00, and the table still works.

A successful search under chaining costs about 1 + a / 2 comparisons for a load factor a, so at a of 1 it is about 1.5 and at a of 2 about 2. That is why chaining degrades gently.

Linear probing

One item per slot. On a collision, look at the next slot, and the next, wrapping round at the end.

DELETED = object()            # the TOMBSTONE, and nothing else is equal to it


class ProbingHashTable:
    """Linear probing: one slot per item, and a collision moves along."""

    def __init__(self, slots=11):
        self.n = slots
        self.keys = [None] * slots
        self.values = [None] * slots
        self.count = 0
        self.probes_used = 0

    def hash(self, key):
        return sum(ord(c) for c in str(key)) % self.n

    def __len__(self):
        return self.count

    def load_factor(self):
        return self.count / self.n

    def _find(self, key):
        """Returns (slot of the key, slot where it could go, probes taken)."""
        start = self.hash(key)
        free = None
        for step in range(self.n):
            i = (start + step) % self.n              # LINEAR probing
            if self.keys[i] is None:                 # never used: the end
                return None, (free if free is not None else i), step + 1
            if self.keys[i] is DELETED:
                if free is None:
                    free = i                         # reusable, but keep looking
                continue
            if self.keys[i] == key:
                return i, i, step + 1
        return None, free, self.n

    def put(self, key, value):
        if self.load_factor() >= 0.7:
            self._rehash()
        found, free, probes = self._find(key)
        self.probes_used += probes
        if found is not None:
            self.values[found] = value
            return found, probes
        if free is None:
            raise RuntimeError("the table is full")
        self.keys[free] = key
        self.values[free] = value
        self.count += 1
        return free, probes

    def get(self, key):
        found, _, probes = self._find(key)
        self.probes_used += probes
        if found is None:
            raise KeyError(f"{key!r} is not in the table")
        return self.values[found]

    def delete(self, key):
        found, _, _ = self._find(key)
        if found is None:
            raise KeyError(f"{key!r} is not in the table")
        value = self.values[found]
        self.keys[found] = DELETED        # a TOMBSTONE, not None
        self.values[found] = None
        self.count -= 1
        return value

    def _rehash(self):
        """Twice the slots, and every key hashed again. Nothing is copied
        across as it stands, because the modulus has changed."""
        old = [(k, v) for k, v in zip(self.keys, self.values)
               if k is not None and k is not DELETED]
        self.n = self.n * 2 + 1
        self.keys = [None] * self.n
        self.values = [None] * self.n
        self.count = 0
        for k, v in old:
            self.put(k, v)

    def clustering(self):
        """The longest run of occupied slots, which is what probing builds."""
        taken = [k is not None and k is not DELETED for k in self.keys]
        longest = run = 0
        for i in range(2 * self.n):            # go round twice for a wrap
            if taken[i % self.n]:
                run += 1
                longest = max(longest, min(run, self.n))
            else:
                run = 0
        return longest

    def show(self):
        for i in range(self.n):
            k = self.keys[i]
            if k is None:
                what = "(empty)"
            elif k is DELETED:
                what = "(deleted)"
            else:
                what = f"{k}:{self.values[i]}"
            print(f"    {i:>2}  {what}")
        print(f"    {len(self)} item(s) in {self.n} slots, "
              f"load factor {self.load_factor():.2f}, "
              f"longest run {self.clustering()}")


subjects = [("Maths", 78), ("Physics", 65), ("Chemistry", 92),
            ("Biology", 71), ("English", 54), ("Marathi", 66),
            ("History", 83)]

p = ProbingHashTable(11)
print("storing the same seven keys, with one slot per item")
print(f"    {'key':<12}{'hash':>6}{'went to':>9}{'probes':>8}")
for key, mark in subjects:
    slot, probes = p.put(key, mark)
    print(f"    {key:<12}{p.hash(key):>6}{slot:>9}{probes:>8}")

print()
print("the table")
p.show()

print()
print("looking them up")
print(f"    {'key':<12}{'value':>7}{'probes':>8}")
for key, _ in subjects:
    before = p.probes_used
    v = p.get(key)
    print(f"    {key:<12}{v:>7}{p.probes_used - before:>8}")

print()
print("why deletion needs a TOMBSTONE and not None")
print("    Chemistry, Marathi and History all hash to 6, so Marathi went to")
print("    slot 7 and History to slot 8: they are a CHAIN through slot 6.")
print()
print("    first, the WRONG way: set the slot to None")
wrong = ProbingHashTable(11)
for key, mark in subjects:
    wrong.put(key, mark)
i = wrong._find("Chemistry")[0]
wrong.keys[i] = None                       # the mistake, deliberately made
wrong.values[i] = None
wrong.count -= 1
for key in ("Marathi", "History"):
    try:
        print(f"      get({key!r}) ->", wrong.get(key))
    except KeyError as e:
        print(f"      get({key!r}) -> KeyError:", e)
print("      both are still in the table, and neither can be found: the")
print("      search stopped at the empty slot.")
print()
print("    now the RIGHT way: a tombstone")
p.delete("Chemistry")
for key in ("Marathi", "History"):
    print(f"      get({key!r}) ->", p.get(key))
p.show()
print("      the tombstone says 'something was here, keep looking'.")

print()
print("a new key can reuse a tombstone")
slot, probes = p.put("Sanskrit", 61)
print(f"    put Sanskrit -> slot {slot} in {probes} probe(s)")
p.show()
munotes.in275

Practical 20: Hashing and Collision Handling

storing the same seven keys, with one slot per item
    key           hash  went to  probes
    Maths            3        3       1
    Physics          2        2       1
    Chemistry        6        6       1
    Biology         10       10       1
    English         10        0       2
    Marathi          6        7       2
    History          6        8       3

the table
     0  English:54
     1  (empty)
     2  Physics:65
     3  Maths:78
     4  (empty)
     5  (empty)
     6  Chemistry:92
     7  Marathi:66
     8  History:83
     9  (empty)
    10  Biology:71
    7 item(s) in 11 slots, load factor 0.64, longest run 3

looking them up
    key           value  probes
    Maths            78       1
    Physics          65       1
    Chemistry        92       1
    Biology          71       1
    English          54       2
    Marathi          66       2
    History          83       3

why deletion needs a TOMBSTONE and not None
    Chemistry, Marathi and History all hash to 6, so Marathi went to
    slot 7 and History to slot 8: they are a CHAIN through slot 6.

    first, the WRONG way: set the slot to None
      get('Marathi') -> KeyError: "'Marathi' is not in the table"
      get('History') -> KeyError: "'History' is not in the table"
      both are still in the table, and neither can be found: the
      search stopped at the empty slot.

    now the RIGHT way: a tombstone
      get('Marathi') -> 66
      get('History') -> 83
     0  English:54
     1  (empty)
     2  Physics:65
     3  Maths:78
     4  (empty)
     5  (empty)
     6  (deleted)
     7  Marathi:66
     8  History:83
     9  (empty)
    10  Biology:71
    6 item(s) in 11 slots, load factor 0.55, longest run 2
      the tombstone says 'something was here, keep looking'.

a new key can reuse a tombstone
    put Sanskrit -> slot 1 in 2 probe(s)
     0  English:54
     1  Sanskrit:61
     2  Physics:65
     3  Maths:78
     4  (empty)
     5  (empty)
     6  (deleted)
     7  Marathi:66
     8  History:83
     9  (empty)
    10  Biology:71
    7 item(s) in 11 slots, load factor 0.64, longest run 5
munotes.in276

Practical 20: Hashing and Collision Handling

Reading the run

The probe count is printed for every insertion and every lookup. Chemistry, Marathi and History all hash to 6, so Chemistry took slot 6, Marathi probed once more to slot 7, and History twice more to slot 8. Those three form a cluster, and finding History costs 3 probes.

English wrapped round. It hashes to 10, found Biology there, and went to slot 0. The % self.n in the probe is what does it, and a table that stops at the last slot instead of wrapping reports itself full while the front is empty. It is the same mistake as the linear queue in [Practical 16: Queues and Circular Queues], for the same reason, with the same cure.

The tombstone, demonstrated

This is the part of the exercise that separates a complete answer from an incomplete one, and the program proves it both ways rather than describing it.

Chemistry at slot 6, Marathi at 7, History at 8, all hashing to 6.

The wrong way. Set slot 6 to None and look for Marathi. The search starts at 6, finds an empty slot, and concludes the key is not there. Marathi and History are both still in the table and neither can be found. The run prints both KeyErrors.

munotes.in277

Practical 20: Hashing and Collision Handling

The right way. Mark slot 6 with a tombstone, a value that means "something was here, keep looking". The search passes over it and finds Marathi at 7 and History at 8, and the run prints 66 and 83.

DELETED = object()      # nothing else in the program is equal to this
...
self.keys[found] = DELETED     # NOT None

Three rules about tombstones, and all three matter:

  1. A search passes over a tombstone and keeps going. Only None ends a search.
  2. An insertion may reuse a tombstone, but must keep looking first in case the key is already

further along. The run shows Sanskrit taking the reusable slot. A program that stops at the first tombstone can store the same key twice.

  1. Tombstones accumulate. They cost probes without holding data, so a table with many

deletions must be rehashed to clear them. That is the practical cost of probing.

object() is used as the tombstone rather than a string like "DELETED", because a string is a value a caller might legitimately store as a key. A fresh object() is equal to nothing but itself.

Clustering, which is linear probing's real weakness

clustering() reports the longest run of occupied slots, and the run shows it growing. A cluster is self-reinforcing: the longer a run is, the more likely the next key is to land in it, and landing anywhere in it extends it. That is called primary clustering, and it is why linear probing degrades sharply.

Two standard cures, worth naming:

MethodThe probe sequenceFixes
Linear probing(h + i) % nnothing; clusters merge
Quadratic probing(h + i squared) % nprimary clustering; keys with the same hash still collide the same way
Double hashing(h1 + i * h2(key)) % nboth; every key has its own step

Double hashing is the best of the three and needs h2 never to be 0 and, for every slot to be reachable, n to be prime.

The load factor, measured

import random
from time import perf_counter


def bad_hash(key, n):
    """The sum of the letters: an ANAGRAM gets the same bucket."""
    return sum(ord(c) for c in key) % n


def better_hash(key, n):
    """Polynomial, which is what real libraries use: position matters."""
    h = 0
    for c in key:
        h = (h * 31 + ord(c)) % 1000003
    return h % n


words = ["stop", "tops", "post", "pots", "opts", "spot",
         "listen", "silent", "enlist", "tinsel",
         "maths", "physics", "chemistry", "biology"]

print("a hash function that ignores the ORDER of the letters")
print(f"    {'word':<12}{'sum of letters':>10}{'polynomial':>12}")
for w in words:
    print(f"    {w:<12}{bad_hash(w, 11):>10}{better_hash(w, 11):>12}")

print()
print("  the six anagrams of 'stop' all land in the same bucket under the")
print("  first function and are spread out under the second:")
for name, fn in (("sum of letters", bad_hash), ("polynomial", better_hash)):
    buckets = {}
    for w in words[:6]:
        buckets.setdefault(fn(w, 11), []).append(w)
    print(f"    {name:<16}{len(buckets)} bucket(s) for 6 anagrams: "
          f"{sorted(len(v) for v in buckets.values())}")

print()
print("what the load factor does to the number of probes")
print("  (linear probing, 1000 slots, random keys. The probes counted are")
print("  those for a SUCCESSFUL search over the finished table, which is the")
print("  figure the standard formula gives.)")


def measure(load, slots=1000, seed=3):
    """Fill the table to `load`, then search for every key and count."""
    rnd = random.Random(seed)
    keys = [None] * slots
    stored = []
    for _ in range(int(load * slots)):
        k = rnd.randrange(10 ** 9)
        i = k % slots
        while keys[i] is not None:
            i = (i + 1) % slots
        keys[i] = k
        stored.append(k)

    total = 0
    for k in stored:
        i, step = k % slots, 1
        while keys[i] != k:
            i = (i + 1) % slots
            step += 1
        total += step

    longest = run = 0
    for i in range(2 * slots):
        if keys[i % slots] is not None:
            run += 1
            longest = max(longest, min(run, slots))
        else:
            run = 0
    return len(stored), total / max(len(stored), 1), longest


print(f"    {'load':>6}{'items':>8}{'measured':>11}{'formula':>10}{'longest run':>14}")
for load in (0.25, 0.50, 0.75, 0.90, 0.95, 0.99):
    items, avg, longest = measure(load)
    formula = (1 + 1 / (1 - load)) / 2
    print(f"    {load:>6.2f}{items:>8}{avg:>11.2f}{formula:>10.2f}{longest:>14}")

print()
print("  the formula is (1 + 1 / (1 - a)) / 2 for a successful search under")
print("  linear probing. The measured column tracks it to about a load of 0.9")
print("  and comes out well below it at 0.99, where one table of 1000 slots")
print("  with one set of keys is far too small a sample to match an average.")
print("  The shape is the point: the cost is flat, then it explodes.")
print()
print("  and with CHAINING instead, the average chain length is just the load")
print("  factor, so a successful search costs about 1 + a / 2 probes:")
print(f"    {'load':>6}{'chaining':>11}{'probing':>10}")
for load in (0.50, 0.90, 0.99, 2.00):
    probing = (1 + 1 / (1 - load)) / 2 if load < 1 else float('inf')
    chaining = 1 + load / 2
    shown = "impossible" if load >= 1 else f"{probing:.2f}"
    print(f"    {load:>6.2f}{chaining:>11.2f}{shown:>12}")

print()
print("  that last row is the real difference: chaining still works at a load")
print("  factor above 1, and probing cannot exist there at all, because there")
print("  is one item per slot.")
munotes.in278

Practical 20: Hashing and Collision Handling

a hash function that ignores the ORDER of the letters
    word        sum of letters  polynomial
    stop                 3           5
    tops                 3           0
    post                 3           5
    pots                 3           2
    opts                 3           3
    spot                 3           4
    listen               6           0
    silent               6           5
    enlist               6           0
    tinsel               6           6
    maths                2           3
    physics              1           3
    chemistry            5           1
    biology              9           8

  the six anagrams of 'stop' all land in the same bucket under the
  first function and are spread out under the second:
    sum of letters  1 bucket(s) for 6 anagrams: [6]
    polynomial      5 bucket(s) for 6 anagrams: [1, 1, 1, 1, 2]

what the load factor does to the number of probes
  (linear probing, 1000 slots, random keys. The probes counted are
  those for a SUCCESSFUL search over the finished table, which is the
  figure the standard formula gives.)
      load   items   measured   formula   longest run
      0.25     250       1.17      1.17             6
      0.50     500       1.50      1.50            17
      0.75     750       2.35      2.50            59
      0.90     900       4.61      5.50           130
      0.95     950       8.57     10.50           569
      0.99     990      18.19     50.50           867

  the formula is (1 + 1 / (1 - a)) / 2 for a successful search under
  linear probing. The measured column tracks it to about a load of 0.9
  and comes out well below it at 0.99, where one table of 1000 slots
  with one set of keys is far too small a sample to match an average.
  The shape is the point: the cost is flat, then it explodes.

  and with CHAINING instead, the average chain length is just the load
  factor, so a successful search costs about 1 + a / 2 probes:
      load   chaining   probing
      0.50       1.25        1.50
      0.90       1.45        5.50
      0.99       1.50       50.50
      2.00       2.00  impossible

  that last row is the real difference: chaining still works at a load
  factor above 1, and probing cannot exist there at all, because there
  is one item per slot.
munotes.in279

Practical 20: Hashing and Collision Handling

The hash function matters more than the collision rule

The first part of the run makes the point that sinks most student hash tables. "Add up the letters" gives the same index to every anagram: stop, tops, post, pots, opts and spot all land in one bucket. Six keys, one bucket, and a hash table that is a linked list.

The polynomial hash, h = h * 31 + ord(c), puts the six into five buckets, because multiplying by 31 at each step makes the position of a letter matter. That is the hash real libraries use, and 31 is the multiplier in Java's String.hashCode.

No collision rule can rescue a bad hash function. Chaining with every key in one bucket is a

linked list; probing with every key in one slot's cluster is a linear search. Get the hash right

first.

munotes.in280

Practical 20: Hashing and Collision Handling

What the load factor costs

LoadItems in 1000 slotsMeasured probesFormulaLongest run
0.252501.171.176
0.505001.501.5017
0.757502.352.5059
0.909004.615.50130
0.959508.5710.50569
0.9999018.1950.50867

Flat, then a cliff. Up to a load of about 0.7 a lookup costs one or two probes. By 0.9 it is nearly five, and at 0.99 it is eighteen and the longest cluster covers 867 of the 1000 slots: the table has become a linear search with extra steps.

The measured column follows the formula (1 + 1 / (1 - a)) / 2 closely to a load of 0.9 and comes out well below it at 0.99. The chapter says so rather than rounding the measurement towards the theory: one table of 1000 slots with one set of keys is a single sample, and the formula is the average over many. The shape is what is being proved, and the shape is unmistakable.

That table is the reason for one line in the probing implementation:

if self.load_factor() >= 0.7:
    self._rehash()

Rehashing makes a bigger table and inserts every key again. It cannot copy the slots across, because the modulus has changed and every index is different. It costs O(n) on the insertion that triggers it, and because it happens rarely the average cost of an insertion is still O(1), which is the same amortised argument as a Python list growing in [Practical 15: the Stack ADT].

Python's own dictionary rehashes at a load factor of two thirds, and Java's HashMap at 0.75.

Chaining against probing

Separate chainingLinear probing
A slot holdsa listone item
Load factormay exceed 1must be below 1
Search cost at load aabout 1 + a / 2about (1 + 1 / (1 - a)) / 2
Degradesgentlysharply past 0.7
Deletionremove from the listneeds a tombstone
Extra memorya list object and links per bucketnone, just spare slots
Cache behaviourpoor, the chains are scatteredgood, the probes are neighbours
Clusteringnoneprimary clustering
Used byJava HashMap before 8, most textbooksPython dict, and most modern libraries

Probing wins in practice on modern hardware, and the row that explains it is the cache: probing looks at neighbouring slots, which arrive in one cache line, where chaining follows pointers to scattered memory. That is why Python's dictionary uses open addressing.

Chaining wins when deletions are frequent or the load factor is unpredictable. It has no tombstones and no upper limit.

munotes.in281

Practical 20: Hashing and Collision Handling

MU's third bullet: where hashing is used

UseThe keysWhy hashing
A dictionary or mapanythingone-step lookup by key, which is the whole purpose
A setanythingmembership in one step
A database indexa column's valuesfind the rows for a value without scanning the table
A compiler's symbol tableidentifiersthe name is looked up on every use
A cache, such as a web cachea URLis this page already held
Removing duplicatesthe itemsone pass, O(n), instead of sorting
Password storagethe passworda cryptographic hash, which is a different subject
Checking a file downloaded correctlythe file's bytesa checksum, also a cryptographic hash
Gita file's contentsthe SHA-1 of the contents is the file's name

The last three are a different kind of hash. A hash table wants a function that is fast and spreads keys evenly. A cryptographic hash, such as SHA-256, additionally must make it infeasible to find the input from the output or to find two inputs with the same output, and it is deliberately slow for password use. They share the word and nothing else, and an examiner may ask for the difference.

And the one every reader has already used: Python's dict and set are hash tables. Every {} and every in test in every program in this module has been doing what this chapter builds by hand.

The cost of each operation

n items, m slots, a the load factor.

OperationAverageWorst case
InsertO(1)O(n), every key in one bucket
SearchO(1)O(n)
DeleteO(1)O(n)
RehashO(n), amortised to O(1) per insertO(n)
Find the smallest keyO(n + m)O(n + m)
Keys in sorted orderO(n log n), by sorting them
SpaceO(n + m)

The worst case is O(n) and it is not theoretical. A hash function that ignores the order of the letters puts every anagram in one bucket; a hash function an attacker can predict lets them choose keys that all collide, which is a real denial-of-service attack and the reason Python randomises its string hash on every run.

Procedure

  1. Write the chaining table and run it. Work out the hash of each subject on paper and check the

bucket.

  1. Change the number of buckets from 7 to 8 and note that the spread changes. Then try 10 with

keys that are multiples of 10.

  1. Write the probing table. Follow the three keys that hash to 6 into slots 6, 7 and 8.
  2. Delete Chemistry by setting its slot to None and try to find Marathi. Then put the tombstone

back.

  1. Insert until the load factor passes 0.7 and confirm the rehash happens and that every key can

still be found.

  1. Write the measurement and extend the table to a load of 0.999.
  2. Replace hash with the polynomial version in both tables and count the collisions before and
munotes.in282

Practical 20: Hashing and Collision Handling

after.

Result

A hash table was implemented twice, with separate chaining over seven buckets and with linear probing over eleven slots, and the hash of every key was computed in the open so that the placement could be checked by hand. Chaining produced three collisions in seven insertions and lookups costing one or two comparisons. Probing placed three keys that hash to the same index into a cluster of three consecutive slots and wrapped one key round the end of the table. Deletion under probing was performed both ways: replacing the slot with None made two keys that were still in the table impossible to find, and a tombstone left both findable. A hash function that adds the letters was shown to place all six anagrams of one word in a single bucket where a polynomial hash spread them over five. The cost of a successful search was measured over 1000 slots at six load factors, rising from 1.17 probes at 0.25 to 18.19 at 0.99 with the longest cluster reaching 867 slots, which is the reason a table is rehashed at about 0.7.

Where marks are lost

  • Using None to mark a deleted slot under probing. Every key that probed past it is lost,

and the run on this page shows two of them.

  • Not wrapping the probe round the end of the table.
  • A hash function that ignores the order of the characters. All the anagrams collide.
  • A table size that is not prime, with keys that share a factor with it.
  • Confusing open hashing with open addressing. The first is chaining and the second is

probing.

  • Saying a hash table is O(1) without the word average, or without saying the worst case is

O(n).

  • Appending a duplicate key on put instead of replacing the value.
  • Never rehashing, so the load factor passes 0.9 and the table becomes a linear search.
  • Stopping an insertion at the first tombstone without checking whether the key is further

along, which stores it twice.

  • Saying a hash table can give the keys in order. It cannot; that is what a tree is for.
  • Confusing a hash table's hash with a cryptographic hash.

For the journal

Write the aim, MU's own wording, the definition of a hash function, a collision and the load factor, and the two collision rules with their old and new names. Then the table of each key's letter sum and its bucket, because the arithmetic is the working. Then the chaining table printed bucket by bucket with the comparison count for each lookup, and the probing table slot by slot with the probe count. Then the tombstone demonstration in both forms, with the two KeyErrors from the wrong one copied out, because that pair is what a complete answer has and an incomplete one does not. Then the anagram table and the load factor measurement. The conclusion: hashing computes where a key belongs instead of comparing, so a lookup is one step on average; collisions are certain, so a hash table is a hash function plus a rule for them; and the rule only works while the load factor is kept low and the hash function spreads the keys.

munotes.in283

Practical 20: Hashing and Collision Handling

Quick revision

  • A hash function turns a key into an index: hash(key) % n. A hash table finds a key by

computing, not comparing, so it is O(1) on average.

  • Collisions are certain, not a defect. n slots holding more than n keys must have them.
  • Separate chaining, or open hashing: each bucket is a list. Load factor may exceed 1,

deletion is trivial, cost about 1 + a / 2.

  • Linear probing, or open addressing: one item a slot, go to the next on a collision, wrapping

round. Load factor must stay below 1, cost about (1 + 1 / (1 - a)) / 2.

  • Deletion under probing needs a tombstone, a marker meaning "keep looking". None ends a

search and loses every key that probed past.

  • An insertion may reuse a tombstone, but must keep looking for the key first, or it stores it

twice. Tombstones accumulate and are cleared by rehashing.

  • Primary clustering: a long run of occupied slots attracts more keys and grows. Quadratic probing

and double hashing reduce it.

  • A good hash function is deterministic, uniform and fast. A prime table size matters. Adding the

letters gives every anagram the same index; a polynomial hash, h * 31 + ord(c), does not.

  • Measured, 1000 slots: 1.17 probes at a load of 0.25, 2.35 at 0.75, 4.61 at 0.90, 18.19 at 0.99

with a cluster of 867 slots. Rehash at about 0.7.

  • Rehashing makes a bigger table and inserts every key again, because the modulus has changed.

O(n) once, O(1) amortised.

  • A hash table cannot give the keys in order, find the smallest, or answer a range query. That is

what a balanced tree is for.

  • Python's dict and set are hash tables using open addressing.
  • A cryptographic hash is a different thing: it must be hard to invert and, for passwords,

deliberately slow.

Questions you should be able to answer

1. What is a hash function and what is a collision? A function that turns a key into an index in a fixed range. A collision is two different keys giving the same index, which is certain rather than avoidable.

munotes.in284

Practical 20: Hashing and Collision Handling

2. Name the two collision-handling methods and the old name of each. Separate chaining, also called open hashing, where a slot holds a list. Linear probing, also called open addressing or closed hashing, where a slot holds one item and a collision moves to the next slot.

3. What is the load factor, and what may it be for each method? Items divided by slots. Under chaining it may exceed 1 and the table still works; under probing it must be below 1, because a slot holds one item, and in practice the table is rehashed at about 0.7.

4. Why must a deleted slot under probing be a tombstone and not empty? Because a search stops at an empty slot. Every key that probed past the deleted one becomes unfindable. On this page, deleting Chemistry that way made Marathi and History impossible to find although both were still in the table.

5. What is the rule for reusing a tombstone on insertion? Remember the first tombstone but keep probing, in case the key is already stored further along. Stopping at the tombstone would store the same key twice.

6. What is primary clustering, and what reduces it? Under linear probing, a run of occupied slots attracts keys anywhere in it and so grows longer, which makes probes longer still. Quadratic probing and double hashing reduce it.

7. Give three properties of a good hash function. Deterministic, so the same key always gives the same index. Uniform, so the keys are spread evenly. Fast, because the whole point of hashing is one step.

8. Why does adding up the letters of a word make a poor hash? Because it ignores their order, so every anagram gets the same index. The six anagrams of "stop" all landed in one bucket here; a polynomial hash spread them over five.

9. What happens to a linear-probing table as the load factor approaches 1? The probes rise steeply. Measured over 1000 slots: 1.17 at 0.25, 2.35 at 0.75, 4.61 at 0.90, 18.19 at 0.99, with the longest cluster reaching 867 slots. The table becomes a linear search.

10. What can a balanced tree do that a hash table cannot? Give the keys in sorted order, find the smallest or largest, and answer a range query. A hash table has no order at all.

11. Why is a table size of a prime number preferred? Because a composite size shares factors with the keys more often. With 100 slots and keys that are all multiples of 10 only ten slots are ever used; with 101 every slot is reachable.

Contents This chapter on its own page

munotes.in285

Module J

The journal and the practical examination

munotes.in

Chapter Thirty

Keeping the Journal, and What Goes on the Page

Syllabus topic MU's evaluation scheme for practical courses: the journal carries 5 internal marks, a certified journal is compulsory for appearing at the practical examination, and at least 80 per cent of the practicals must be completed

Aim

To keep a journal that satisfies MU's requirements: the form of one entry, what must be in it, what the teacher signs, and what to do about a practical that would not run.

Why this chapter exists

Two sentences in MU's evaluation scheme, printed under the practical paper pattern:

Certified Journal is compulsory for appearing at the time of Practical Exam

Minimum 80% practical are required to be completed

Read them as what they are: conditions of entry. A student with a term of good work and no certified journal does not sit the paper. And the journal itself carries 5 of the 20 internal marks, with the other 15 for the work it records, so between them the journal and the practicals it documents are 20 of the paper's 50 marks.

Eighty per cent of twenty practicals is sixteen. There are twenty exercises in this paper, ten in each module, so at least sixteen must be completed and in the journal. It does not say which sixteen and it does not say eight from each module, but a journal with all ten of Module 2 and six of Module 1 belongs to a student who cannot answer Q.1, which is half the written paper.

The advice that follows from that is short: do all twenty. The four you are allowed to miss exist for the week you were ill.

What "certified" means

Signed by the teacher who supervised the work, and stamped by the department. That is all it means, and it has two consequences worth being clear about.

It is signed as the term goes on, not at the end. A teacher signs an entry because they saw the program run. A journal produced complete in the last week has nothing to certify, and a teacher who signs it anyway is certifying something they did not see.

A correct journal that is unsigned is not a certified journal. The signature is the point.

The form of one entry

Every college has its own printed format and yours takes precedence over this one. What follows is the form every format is a version of, and it is the form every chapter of this book is laid out in for that reason.

PartWhat goes in it
Practical number and datethe number from MU's list, and the date you did it
AimMU's own wording for the exercise. Copy it; do not paraphrase it
Theory, or what you need to knowhalf a page: the idea, the calls or the structure, in your own words
Algorithmnumbered steps, in words, before any code
Programthe whole program, with its comments
Outputwhat the run actually printed, copied from the screen
Conclusionone or two sentences: what the exercise established
Signaturethe teacher's, with the date
munotes.in286

Keeping the Journal, and What Goes on the Page

Four notes on that list, and each is a mark somebody loses.

The aim is MU's wording. An examiner reading the journal is checking it against the syllabus, and the closer the aim is to her words the less there is to argue about. The table at the end of this chapter gives all twenty.

The algorithm comes before the program and is in words. It is not the program written out again in English. It is the steps: create the segment, fork, the child writes, the parent waits, the parent reads, remove the segment. For a data structure it is the operation being implemented: compare with the root, go left if smaller, and so on.

The output is what the machine printed. Not what it should have printed. If the output in your journal cannot be produced by the program above it, that is worse than a wrong answer, and it is the single thing most likely to be noticed: this whole book was written with a checker that runs every listing and refuses any output it did not produce, and it caught an error in every chapter of Module 1.

The conclusion is not a summary of the aim. It is what you now know that you did not before. "Shared memory allowed two processes to exchange data without the kernel copying it, and the segment had to be removed by hand or it stayed on the machine" is a conclusion. "In this practical we studied shared memory" is not.

A worked entry, in full

This is what one page looks like. It is Practical 12 from [Practical 12: Singly Linked Lists], shortened to fit a page as a journal entry must be.

Practical No. 12                                    Date: __ / __ / ____

AIM
Building and Using Singly Linked Lists: construct a dynamic singly linked
list with basic operations; apply linked lists to simulate scenarios such
as managing a playlist or to-do list; compare static (array) vs dynamic
(linked) approaches.

THEORY
A singly linked list is a chain of nodes. Each node holds one item and a
reference to the next node; the last node's reference is None. The list
itself is a reference to the first node, the head. A tail reference is
also kept so that appending is one step rather than a walk, and a count
so that len() need not walk either.
Item n is reached by walking n links, so reading by position is O(n),
where an array is O(1). Inserting at the head is O(1), where an array
must shift every item.

ALGORITHM (prepend)
1. Make a new node holding the item, whose next is the current head.
2. Set the head to the new node.
3. If the tail was None, set the tail to the new node as well.
4. Add one to the count.

PROGRAM
   ... the class, as written in the practical ...

OUTPUT
   after three appends : 'Physics' -> 'Chemistry' -> 'Maths' -> None
   after one prepend   : 'English' -> 'Physics' -> 'Chemistry' -> ...
   search('Maths')     : 4
   remove('English')   : English
   after reverse       : 'Biology' -> 'Chemistry' -> 'Physics' -> None

CONCLUSION
A singly linked list was built with insertion at both ends, search,
removal and reversal. Removal needs a trailing pointer, because a node
cannot reach its predecessor. Measured against a Python list, building
20000 items at the front was 58 times faster with links and reading the
middle item was 4114 times slower, so neither structure is better: the
choice depends on which operation the program does most.

                                        Signature: ________________
munotes.in287

Keeping the Journal, and What Goes on the Page

The index page

The first page of the journal is an index, and it is the page an examiner looks at first because it is how they see whether the 80 per cent is met.

ColumnWhat goes in it
Practical No.1 to 20
TitleMU's own heading for the exercise
Page No.where the entry starts
Datewhen it was done
Signaturethe teacher's

A blank row is a practical not done, and four blank rows is the limit.

What to do about a practical that would not run

It happens, and the wrong answer is to leave the entry out or to copy somebody else's output.

Write the entry anyway, with the program as far as you got, the actual error message, and a conclusion saying what you established and where it stopped. A teacher can sign that, because it is a true record, and it is worth far more than a blank page. Three examples of what that looks like:

  • "The program compiled but shmget failed with errno 28, ENOSPC. ipcs -m showed 4096

segments on the machine, which is the limit, left behind by earlier runs. ipcrm cleared them and the program then ran; the output above is from that second attempt."

  • "pthread_create gave undefined reference at the link step. The compile command was missing

-pthread. Corrected command and output above."

  • "The threaded version was not faster than the sequential one. nproc reports 2 on this machine

and a direct test showed two processes taking twice as long as one, so there is one processor's worth of throughput and two threads cannot overlap. The measurement is recorded as it came out."

That third one is not a failure at all. It is the honest result of the experiment in [Practical 3: Threading and Single Thread Control Flow], and it is worth more than a fabricated speedup.

munotes.in288

Keeping the Journal, and What Goes on the Page

What not to put in a journal

  • Output the program cannot produce. The commonest fault and the easiest to catch.
  • A program copied from a friend, with their variable names. Two identical journals are two

journals with a problem.

  • Screenshots of the whole desktop. Copy the text.
  • The program with no comments, when the version you ran had them.
  • An aim in your own words where MU's own words were available.
  • A conclusion that repeats the aim.
  • Pages in the wrong order, or entries out of numerical order.
  • Nothing about the errors you hit. The errors are the part a teacher can tell you learned

something from.

The twenty aims, in MU's own words

For copying into the journal. Every line below is her printed heading and the sentences under it.

Module 1: Practical based on Principles of Operating Systems

No.MU's heading and her bullets
1Process Communication using Shared Memory. Understand shared memory concepts in inter-process communication. Implement producer-consumer synchronization using shared memory and semaphores. Explore issues of race conditions and how to avoid them.
2Process Communication using Message Passing. Use message queues/pipes to solve the producer-consumer problem. Compare and contrast shared memory vs. message-passing approaches. Analyze blocking vs. non-blocking communication.
3Threading and Single Thread Control Flow. Practice thread creation and basic thread lifecycle using standard libraries (e.g., pthreads or Java threads). Observe execution order, thread joining, and delays. Measure execution time for sequential vs threaded execution.
4Multi-threading and Fibonacci Generation. Implement multi-threading to generate and print Fibonacci sequences. Explore thread safety, synchronization when accessing shared variables. Introduce concepts of thread pooling and task delegation.
5Process Synchronization and Bounded Buffer Problem. Simulate producer-consumer bounded buffer using mutex and semaphores. Implement buffer control with synchronized access. Introduce circular queue techniques for managing shared buffers.
6Readers-Writers Problem, Synchronization in Shared Access. Implement reader and writer prioritization. Use semaphores to allow multiple readers or exclusive writer access. Extend to fairness in access and deadlock prevention.
7CPU Scheduling Algorithms (Part 1), FCFS and Non-preemptive Scheduling. Simulate First-Come First-Serve scheduling. Extend implementation to general non-preemptive scheduling. Analyze waiting time, turnaround time, and Gantt chart generation.
8CPU Scheduling Algorithms (Part 2), Round Robin. Implement Round Robin scheduling with configurable time quantum. Compare with FCFS: fairness, turnaround, response time. Track context switches and improve queue management.
9Memory Management Techniques. Simulate FIFO and LRU page replacement using page reference strings. Measure hit/miss ratios under different reference patterns. Extend to include frames and memory constraints.
10Disk Scheduling and Simple File System Design. Simulate FCFS, SSTF, C-SCAN, C-LOOK, RSS for disk head movement. Design a basic file system structure with block allocation, directory management, and file operations (create, read, delete).
munotes.in289

Keeping the Journal, and What Goes on the Page

Module 2: Practical based on Data Structures

No.MU's heading and her bullets
11Exploring Abstract Data Types (ADT) and Custom Structures. Create and manipulate structures to model ADTs like Student, Book, or Employee. Implement basic operations (create, update, delete) using structures. Reflect on differences between primitive and abstract data types.
12Building and Using Singly Linked Lists. Construct a dynamic singly linked list with basic operations. Apply linked lists to simulate scenarios such as managing a playlist or to-do list. Compare static (array) vs dynamic (linked) approaches.
13Polynomial Operations Using Linked Lists. Represent polynomials using linked lists. Perform polynomial addition and subtraction by merging lists. Use structured representation to reinforce node manipulation.
14Working with Doubly Linked Lists. Create a doubly linked list with forward and backward traversal. Implement insertion/deletion at head, tail, and specific positions. Use in scenarios like browser history or undo-redo features.
15Implementing and Using Stack ADT. Implement push, pop, peek using arrays or linked lists. Solve problems like delimiter matching or undo mechanism. Convert expressions from prefix to postfix and evaluate them.
16Understanding Queues and Circular Queues. Develop linear and circular queues to simulate task scheduling. Perform enqueue and dequeue with wrap-around logic. Discuss memory utilization in linear vs circular queues.
17Tree Traversals and Binary Search Trees. Create a binary search tree (BST) from a dataset. Perform and visualize in-order, pre-order, and post-order traversals. Use traversal results to derive sorted sequences.
18Balanced Trees and Priority Queues. Insert values and observe AVL tree rebalancing. Construct min-heaps or max-heaps and simulate priority queues. Use priority queues to manage task priorities (e.g., patient triage, job scheduling).
19Graph Representations and Traversals. Represent graphs using adjacency matrices and lists. Implement BFS and DFS to explore graph components. Use graphs for mapping routes or exploring social networks.
20Hashing Concepts and Collision Handling. Implement a hash table with chaining or linear probing. Simulate insertion, deletion, and search with collisions. Discuss practical hashing applications (e.g., dictionary lookup, indexing).

Which chapter of this book is which practical

MU's practicalThe chapter or chapters
1[Practical 1: Process Communication using Shared Memory] and [Practical 1 continued: the Race Condition, Semaphores, and Producer and Consumer]
2[Practical 2: Process Communication with Pipes] and [Practical 2 continued: Message Queues, Blocking and Non-blocking]
3[Practical 3: Threading and Single Thread Control Flow]
4[Practical 4: Multi-threading and Fibonacci Generation]
5[Practical 5: Process Synchronisation and the Bounded Buffer]
6[Practical 6: the Readers-Writers Problem]
7[Practical 7: CPU Scheduling, FCFS and Non-preemptive Scheduling]
8[Practical 8: CPU Scheduling, Round Robin]
9[Practical 9: Memory Management, FIFO and LRU Page Replacement]
10[Practical 10: Disk Scheduling] and [Practical 10 continued: a Simple File System]
11[Practical 11: Abstract Data Types and Custom Structures]
12[Practical 12: Singly Linked Lists]
13[Practical 13: Polynomial Operations Using Linked Lists]
14[Practical 14: Doubly Linked Lists]
15[Practical 15: the Stack ADT] and [Practical 15 continued: Prefix to Postfix, and Evaluating It]
16[Practical 16: Queues and Circular Queues]
17[Practical 17: Binary Search Trees and Tree Traversals]
18[Practical 18: AVL Trees and Rebalancing] and [Practical 18 continued: Heaps and Priority Queues]
19[Practical 19: Graph Representations and Traversals]
20[Practical 20: Hashing and Collision Handling]
munotes.in290

Keeping the Journal, and What Goes on the Page

Two chapters carry no practical number, because they are groundwork rather than exercises: [The Laboratory from Zero: gcc, a Program, and the Manual] and [Processes: fork, wait, exec, and Why Two Programs Need to Talk] for Module 1, and [Python for Data Structures: the Tools This Module Uses] for Module 2.

Result

MU's requirements for the journal were set out: certification by the supervising teacher, at least sixteen of the twenty practicals completed, and 5 of the 20 internal marks for the journal itself. The form of an entry was given and worked in full for one practical, the index page described, and MU's own printed aim for all twenty exercises tabulated for copying.

Quick revision

  • A certified journal is compulsory to sit the practical examination. Certified means signed

by the teacher who supervised the work, as the term goes on.

  • At least 80 per cent of the practicals must be completed: 16 of the 20.
  • The journal is 5 of the 20 internal marks; the work it records is the other 15.
  • One entry: number and date, MU's aim, theory, algorithm in words, program, the real output,

conclusion, signature.

  • The algorithm is in words and comes before the program. It is not the program in English.
  • The output is what the machine printed. Output a program cannot produce is the easiest fault

to catch.

  • The conclusion is what you learned, not a restatement of the aim.
  • A practical that would not run still gets an entry: the program as far as you got, the real

error message, and what you established.

  • The index page is what an examiner reads first, because it shows whether the 80 per cent is met.
  • Copy MU's aim in her own words. All twenty are tabulated in this chapter.

Questions you should be able to answer

1. What does a certified journal mean, and why does it matter? Signed by the teacher who supervised the work and stamped by the department. It matters because MU makes it a condition of being allowed to sit the practical examination at all.

munotes.in291

Keeping the Journal, and What Goes on the Page

2. How many practicals must be completed? At least 80 per cent of them, which is 16 of the 20.

3. How many marks does the journal carry? Five of the twenty internal marks. The other fifteen are for the practical work, the hands-on tests and the demonstrations that the journal records.

4. What is the difference between the algorithm and the program in an entry? The algorithm is the steps in words, written before any code: create the segment, fork, the child writes, the parent waits. The program is the code. An algorithm that is the program translated into English is not an algorithm.

5. A program would not run. What goes in the journal? The entry, with the program as far as you got, the actual error message, and a conclusion saying what you established and where it stopped. A true record can be signed; a blank page cannot.

6. What is the commonest fault in a journal? Output that the program above it could not have produced. It is the easiest thing for an examiner to check and it costs more than a wrong answer would.

7. Why copy MU's own wording for the aim? Because the examiner is checking the journal against the syllabus, and her words leave nothing to argue about.

Contents This chapter on its own page

munotes.in292

Chapter Thirty-One

A Worked Practical Paper: Q.1 and Q.2

Syllabus topic MU's printed paper pattern for a practical course: a Semester End Practical Examination of 2 hours for 30 marks, Q.1 on Module 1 for 15 and Q.2 on Module 2 for 15

Aim

To know the shape of the paper, how to spend the two hours, and what a complete answer looks like.

MU's printed paper pattern

From her evaluation scheme for practical courses, word for word:

A Semester End Practical Examination of 2 hours duration for 30 marks as per the paper pattern

given below.

QuestionPractical question based onMarks
Q. 1Module 115
Q. 2Module 215

And the two conditions:

1. Certified Journal is compulsory for appearing at the time of Practical Exam 2. Minimum 80%

practical are required to be completed

Three things follow, and the first surprises students who sat the first-year practicals.

There is no viva question. The first-year practical papers, under item 6.5 (R), print Q.1 for 12, Q.2 for 12 and a viva for 6. This paper, under item 6.14 (N), prints two questions and nothing else. An examiner will still ask you about your program at the machine, because that is how a practical examination is conducted, but there is no third question carrying marks.

The two questions are equal and from different modules. Q.1 is C on Linux and Q.2 is Python. Neither can be skipped, and a student fluent in one has a ceiling of 15 out of 30.

Two hours for two programs, write-ups included. That is the real constraint, and the next section is about it.

How to spend the two hours

MinutesWhat
0 to 5read both questions, both of them, before touching the keyboard
5 to 10write the aim and the algorithm for Q.1 on paper
10 to 40type Q.1, compile, run, fix
40 to 50write up Q.1: program, output, conclusion
50 to 55write the aim and the algorithm for Q.2
55 to 85type Q.2, run, fix
85 to 95write up Q.2
95 to 115go back to whichever is weaker, and check both outputs against their programs
115 to 120check your name, roll number and the question numbers on every sheet

Four pieces of advice that are worth more than any program.

Read both questions first. One of them is always easier for you, and it should be done first, finished and written up. A student who starts at Q.1 because it is numbered 1 and runs out of time loses marks on a question they could have answered.

Write the algorithm before the program, on paper, even under time pressure. It carries marks of its own, it takes four minutes, and it stops the commonest disaster: a program that is half written in two different designs.

Get something running early, then extend it. A round robin that handles no arrival times and prints the right averages for a job set where everything arrives at 0 is worth far more than a complete one that does not compile. Compile after every ten lines.

munotes.in293

A Worked Practical Paper: Q.1 and Q.2

Copy the output from the screen, not from your head. It is the single thing most likely to be checked against the program, and the reason this whole book was written with a checker that runs every listing.

Q.1, worked: round robin scheduling

Q.1 Write a program to simulate Round Robin CPU scheduling for the following set of

processes with a time quantum of 3. Display the Gantt chart, and compute the waiting time and

turnaround time of each process and their averages. [15] | Process | Arrival time | Burst time

| | --- | ---: | ---: | | P1 | 0 | 5 | | P2 | 1 | 4 | | P3 | 2 | 2 | | P4 | 3 | 1 |

The algorithm, which goes on the paper first

  1. Set left[i] to the burst time of every process, and the clock to 0.
  2. Put every process that has arrived by the clock on to the ready queue, in arrival order.
  3. If the queue is empty, the processor is idle: move the clock to the next arrival and go to 2.
  4. Take the process at the front of the queue. Run it for the quantum, or for what it has left,

whichever is less. Add that to the clock.

  1. Put on to the queue every process that arrived while it was running.
  2. If it has finished, record the clock as its completion time. Otherwise put it at the back of

the queue.

  1. Repeat from 2 until every process has finished.
  2. For each: turnaround time is completion minus arrival, waiting time is turnaround minus burst.

Average each over the number of processes.

Step 5 is the one that carries a mark and is the one most often left out: a process that arrives during a slice joins the queue before the process that has just been preempted.

The program

/* Q.1  Round robin CPU scheduling with a given time quantum.
   Compute the Gantt chart, waiting time and turnaround time.        */

#include <stdio.h>

#define N 4                 /* processes */
#define Q 3                 /* the time quantum                      */

int main(void)
{
    char name[N][4]     = { "P1", "P2", "P3", "P4" };
    int  arrival[N]     = {  0,    1,    2,    3  };
    int  burst[N]       = {  5,    4,    2,    1  };
    int  left[N], finish[N];
    int  queue[64], head = 0, tail = 0, count = 0;
    int  now = 0, done = 0, arrived = 0, i;

    for (i = 0; i < N; i++)
        left[i] = burst[i];

    printf("Gantt chart\n  ");

    while (done < N) {
        while (arrived < N && arrival[arrived] <= now) {
            queue[tail++] = arrived++;
            count++;
        }
        if (count == 0) {                      /* the processor is idle */
            printf("|idle %d", now);
            now = arrival[arrived];
            continue;
        }

        i = queue[head++];
        count--;

        int run = left[i] < Q ? left[i] : Q;
        printf("|%s %d", name[i], now);
        now += run;
        left[i] -= run;

        while (arrived < N && arrival[arrived] <= now) {
            queue[tail++] = arrived++;
            count++;
        }
        if (left[i] == 0) {
            finish[i] = now;
            done++;
        } else {
            queue[tail++] = i;
            count++;
        }
    }
    printf("| %d\n\n", now);

    printf("  P   AT  BT  CT  TAT  WT\n");
    int tat_total = 0, wt_total = 0;
    for (i = 0; i < N; i++) {
        int tat = finish[i] - arrival[i];
        int wt  = tat - burst[i];
        tat_total += tat;
        wt_total  += wt;
        printf("  %-3s %2d  %2d  %2d  %3d  %2d\n",
               name[i], arrival[i], burst[i], finish[i], tat, wt);
    }
    printf("\n  average turnaround time = %d / %d = %.2f\n",
           tat_total, N, (double) tat_total / N);
    printf("  average waiting time    = %d / %d = %.2f\n",
           wt_total, N, (double) wt_total / N);
    return 0;
}
munotes.in294

A Worked Practical Paper: Q.1 and Q.2

Gantt chart
  |P1 0|P2 3|P3 6|P4 8|P1 9|P2 11| 12

  P   AT  BT  CT  TAT  WT
  P1   0   5  11   11   6
  P2   1   4  12   11   7
  P3   2   2   8    6   4
  P4   3   1   9    6   5

  average turnaround time = 34 / 4 = 8.50
  average waiting time    = 22 / 4 = 5.50

Checking it by hand, which is what you do while the write-up dries

The Gantt chart says P1 from 0 to 3, P2 from 3 to 6, P3 from 6 to 8, P4 from 8 to 9, P1 from 9 to 11, P2 from 11 to 12.

ProcessCompletionTurnaround = CT - ATWaiting = TAT - BT
P11111 - 0 = 1111 - 5 = 6
P21212 - 1 = 1111 - 4 = 7
P388 - 2 = 66 - 2 = 4
P499 - 3 = 66 - 1 = 5
  • average turnaround time = (11 + 11 + 6 + 6) / 4 = 34 / 4 = 8.50
  • average waiting time = (6 + 7 + 4 + 5) / 4 = 22 / 4 = 5.50

And a check that costs nothing: the last completion is 12, and the total burst time is 5 + 4 + 2 + 1 = 12. They agree, so the processor was never idle and no time was lost. If the last completion time exceeds the total burst time and no idle gap appears in your chart, something is wrong.

munotes.in295

A Worked Practical Paper: Q.1 and Q.2

The conclusion for the write-up

Round robin scheduling was simulated with a time quantum of 3 over four processes with different

arrival times, using a queue of process indices as the ready queue. The Gantt chart shows six

slices and the processor never idle, the last completion at 12 equalling the total burst time of

12. The average turnaround time was 8.50 and the average waiting time 5.50 units. A process

arriving during a slice was added to the queue before the preempted process, which is what

determines the order of the chart.

Q.2, worked: a binary search tree and its traversals

Q.2 Write a program to create a binary search tree from the dataset 55, 30, 80, 20, 45, 70,

90, 35 and to perform the in-order, pre-order and post-order traversals. Also search for a key

that is present and one that is not. [15]

The algorithm

  1. A node holds a key, a left child and a right child, both None at first.
  2. To insert a key: if the subtree is empty, the key becomes a new node. If the key is smaller

than the node's, insert into the left subtree; if larger, into the right; if equal, do nothing.

  1. In-order: the left subtree, then the node, then the right subtree.
  2. Pre-order: the node, then the left subtree, then the right.
  3. Post-order: the left subtree, then the right, then the node.
  4. To search: compare with the node; go left if smaller, right if larger, stop when equal or when

the subtree is empty.

The program

# Q.2  Create a binary search tree from a dataset and perform the
#      in-order, pre-order and post-order traversals.

class Node:
    def __init__(self, key):
        self.key = key
        self.left = None
        self.right = None


class BST:
    def __init__(self):
        self.root = None

    def insert(self, key):
        self.root = self._insert(self.root, key)

    def _insert(self, node, key):
        if node is None:
            return Node(key)
        if key < node.key:
            node.left = self._insert(node.left, key)
        elif key > node.key:
            node.right = self._insert(node.right, key)
        return node                      # a duplicate is ignored

    def search(self, key):
        here = self.root
        while here is not None:
            if key == here.key:
                return True
            here = here.left if key < here.key else here.right
        return False

    def in_order(self, node, out):
        if node is not None:
            self.in_order(node.left, out)
            out.append(node.key)
            self.in_order(node.right, out)
        return out

    def pre_order(self, node, out):
        if node is not None:
            out.append(node.key)
            self.pre_order(node.left, out)
            self.pre_order(node.right, out)
        return out

    def post_order(self, node, out):
        if node is not None:
            self.post_order(node.left, out)
            self.post_order(node.right, out)
            out.append(node.key)
        return out

    def height(self, node):
        if node is None:
            return -1
        return 1 + max(self.height(node.left), self.height(node.right))


data = [55, 30, 80, 20, 45, 70, 90, 35]
t = BST()
for k in data:
    t.insert(k)

print("dataset      :", data)
print("in-order     :", t.in_order(t.root, []))
print("pre-order    :", t.pre_order(t.root, []))
print("post-order   :", t.post_order(t.root, []))
print("height       :", t.height(t.root), "edges")
print("search 45    :", t.search(45))
print("search 60    :", t.search(60))
print("in-order is sorted:", t.in_order(t.root, []) == sorted(data))
munotes.in296

A Worked Practical Paper: Q.1 and Q.2

dataset      : [55, 30, 80, 20, 45, 70, 90, 35]
in-order     : [20, 30, 35, 45, 55, 70, 80, 90]
pre-order    : [55, 30, 20, 45, 35, 80, 70, 90]
post-order   : [20, 35, 45, 30, 70, 90, 80, 55]
height       : 3 edges
search 45    : True
search 60    : False
in-order is sorted: True

The tree, which goes on the paper

            55
          /    \
        30      80
       /  \    /  \
     20    45 70    90
          /
        35

Read the three traversals off it and check them against the program:

  • in-order, left, node, right: 20, 30, 35, 45, 55, 70, 80, 90. Sorted, which is the mark

to make in the conclusion.

  • pre-order, node, left, right: 55, 30, 20, 45, 35, 80, 70, 90. The root first, at every

level.

  • post-order, left, right, node: 20, 35, 45, 30, 70, 90, 80, 55. The root last.

The height is 3 edges, because 35 is at depth 3.

The conclusion for the write-up

A binary search tree was built from the eight given keys by inserting each into the subtree

determined by comparison with the current node. The three depth-first traversals were performed

recursively. The in-order traversal gave 20, 30, 35, 45, 55, 70, 80, 90, which is the dataset in

sorted order, and that is a consequence of the search property: every key in a left subtree is

smaller than its node and every key in a right subtree is larger. The tree has height 3, and a

search took at most 4 comparisons, one per level.

A bank of likely questions

MU sets one question per module, so an examiner picks from a list of this kind. For each: the chapter to revise, what the answer has to contain, and the trap.

Module 1

Likely questionReviseThe trap
Producer-consumer with shared memory and semaphores[Practical 1 continued: the Race Condition, Semaphores, and Producer and Consumer]union semun must be declared; empty starts at the buffer size, not 0; remove both the segment and the semaphore set
Two processes exchanging data through shared memory[Practical 1: Process Communication using Shared Memory]shmat fails with (void *) -1, not NULL; IPC_RMID at the end
Producer-consumer with a pipe[Practical 2: Process Communication with Pipes]close the end you do not use, or the reader never sees end of file
Message passing with a message queue[Practical 2 continued: Message Queues, Blocking and Non-blocking]the size is sizeof m.mtext, not sizeof m; mtype first and positive; send an end marker
Create threads, join them, and time sequential against threaded[Practical 3: Threading and Single Thread Control Flow]-pthread; the pthread calls return an error number, not -1; say how many processors the machine has
Fibonacci with threads, and a shared counter made safe[Practical 4: Multi-threading and Fibonacci Generation]the sequence is inherently sequential; a mutex round the counter; unsigned long
Bounded buffer with a mutex and two semaphores[Practical 5: Process Synchronisation and the Bounded Buffer]wait on the semaphore before taking the mutex; % SIZE on both indices
Readers-writers with semaphores[Practical 6: the Readers-Writers Problem]the first reader locks and the last unlocks; print the greatest number of readers inside at once
FCFS, SJF or priority scheduling with a Gantt chart[Practical 7: CPU Scheduling, FCFS and Non-preemptive Scheduling]turnaround is CT minus AT; show the idle gap; break ties by arrival
Round robin with a given quantum[Practical 8: CPU Scheduling, Round Robin]arrivals during a slice join before the preempted process; count the context switches
FIFO and LRU page replacement with hit and miss ratios[Practical 9: Memory Management, FIFO and LRU Page Replacement]LRU refreshes the stamp on a hit as well; show the frames at every step
Disk scheduling: FCFS, SSTF, C-SCAN, C-LOOK[Practical 10: Disk Scheduling]count the movement to the end of the disk and the return jump; state the direction
A simple file system with create, read and delete[Practical 10 continued: a Simple File System]round the block count up; free the blocks on delete; print the bitmap
munotes.in297

A Worked Practical Paper: Q.1 and Q.2

Module 2

Likely questionReviseThe trap
An ADT for Student, Book or Employee with create, update, delete[Practical 11: Abstract Data Types and Custom Structures]the operations belong to the collection; raise, do not return None; check an unknown field name
A singly linked list with insert, delete, search and reverse[Practical 12: Singly Linked Lists]remove needs a trailing pointer; move the tail when the last node goes; save after before reversing a link
Polynomial addition or subtraction by merging linked lists[Practical 13: Polynomial Operations Using Linked Lists]drop a term whose coefficients cancel; the merge must not stop when one list ends
A doubly linked list, or browser history, or undo-redo[Practical 14: Doubly Linked Lists]four link assignments per insertion; a new action discards the redo chain
A stack over an array and over a linked list[Practical 15: the Stack ADT]the top is the end of an array and the head of a chain; pop on empty raises
Delimiter matching, or prefix to postfix, or evaluating postfix[Practical 15 continued: Prefix to Postfix, and Evaluating It]read prefix right to left; the operand order is opposite in the two algorithms; show the trace
A circular queue with enqueue, dequeue and wrap-around[Practical 16: Queues and Circular Queues]% size on both indices; keep a count, or leave one cell empty
A BST with insertion, the traversals and deletion[Practical 17: Binary Search Trees and Tree Traversals]in-order gives the sorted order; deletion has three cases; check the in-order walk is still sorted
An AVL tree with insertions and the rebalancing shown[Practical 18: AVL Trees and Rebalancing]the balance factor at every node; LR and RL need two rotations; refresh the lower node's height first
A min-heap or max-heap, and heapsort[Practical 18 continued: Heaps and Priority Queues]parent (i - 1) // 2; extraction moves the last leaf to the root; print the array
A priority queue for patient triage or job scheduling[Practical 18 continued: Heaps and Priority Queues]put an arrival number between the priority and the payload
A graph as a matrix and a list, with BFS and DFS[Practical 19: Graph Representations and Traversals]the matrix is symmetric; BFS uses a queue and DFS a stack; mark a vertex as seen when it is pushed
A hash table with chaining or linear probing[Practical 20: Hashing and Collision Handling]deletion under probing needs a tombstone; wrap the probe round; a prime table size
munotes.in298

A Worked Practical Paper: Q.1 and Q.2

What an examiner is marking

Out of 15, in about these proportions. Your college may weight them differently and the shape will be the same.

PartRoughlyWhat loses it
Aim and algorithm2no algorithm, or the program written out in English
The program, correct and compiling6it does not compile; the wrong structure altogether
Error checking and edge cases2no check on a system call; no empty case
Output, and matching the program3output the program cannot produce
Conclusion1a restatement of the aim
Neatness, naming, comments1one-letter names throughout, no comments

The largest single avoidable loss is output that does not match the program. It is three marks and it is the easiest thing in the paper to get right: copy the screen.

Result

MU's printed paper pattern was set out, two questions of 15 marks each with no viva question, and a plan for the two hours given. One question from each module was answered in full at examination length: round robin scheduling in C with the Gantt chart, the per-process figures and the two averages, checked by hand and against the total burst time; and a binary search tree in Python with the three traversals, the tree drawn and the in-order walk shown to be the dataset in sorted order. A bank of twenty-six likely questions was tabulated with the chapter to revise and the trap in each.

munotes.in299

A Worked Practical Paper: Q.1 and Q.2

Quick revision

  • The paper is 2 hours, 30 marks: Q.1 on Module 1 for 15, Q.2 on Module 2 for 15.

No viva question in this scheme.

  • A certified journal and 16 of the 20 practicals are conditions of sitting it.
  • Read both questions first and answer the easier one first, completely.
  • Write the aim and the algorithm on paper before typing. It carries marks and it prevents a

half-designed program.

  • Get something running early, then extend. Compile every ten lines.
  • Copy the output from the screen. Output a program cannot produce is the largest avoidable loss.
  • For a scheduling answer, check that the last completion time equals the total burst time when

there is no idle gap.

  • For a tree answer, check that the in-order traversal is the dataset sorted.
  • Leave five minutes for your name, roll number and the question numbers on every sheet.

Questions you should be able to answer

1. What is the paper pattern for this practical? Two hours, 30 marks: Q.1 a practical question on Module 1 for 15 marks and Q.2 one on Module 2 for

  1. There is no separate viva question in this scheme.

2. What are the two conditions for being allowed to sit it? A certified journal, and at least 80 per cent of the practicals completed, which is 16 of the 20.

3. Which question should be answered first? Whichever is easier for you. Read both before starting; the numbering is not an instruction.

4. What is the cheapest check on a CPU scheduling answer? That the last completion time equals the sum of the burst times, when the Gantt chart shows no idle gap. If it is larger and there is no gap, something is wrong.

5. What is the cheapest check on a binary search tree answer? That the in-order traversal is the dataset in sorted order. If it is, the search property holds at every node.

6. What is the largest avoidable loss of marks? Output that the program above it could not have produced. It is worth about three marks and it is prevented by copying the screen.

7. Why write the algorithm when time is short? Because it carries about two marks of its own, takes four minutes, and stops the commonest disaster, which is a program half written to two different designs.

Contents This chapter on its own page

munotes.in300

The rest of this subject

These notes are cut from the University's printed syllabus. Open the syllabus itself for the same subject.

Issue
Done!