Ye ☺ CS13002 Programming and Data Structures Spring semester Introduction What is a digital computer? A computer is a machine that can perform computation. It is difficult to give a precise definition of computation. Intuitively, a computation involves the following three components: • Input: The user gives a set of input data. • Processing: The input data is processed by a well-defined and finite sequence of steps. • Output: Some data available from the processing step are output to the user. Usually, computations are carried out to solve some meaningful and useful problems. One supplies some input instances for the problem, which are then analyzed in order to obtain the answers for the instances. Types of problems 1. Functional problems A set of arguments a 1 ,a 2 ,...,a n constitute the input. Some function f(a 1 ,a 2 ,...,a n ) of the arguments is calculated and output to the user. 2. Decision problems These form a special class of functional problems whose outputs are "yes" and "no" (or "true" and "false", or "1" and "0", etc). 3. Search problems Given an input object, one tries to locate some particular configuration pertaining to the object and outputs the located configuration, or "failure" if no configuration can be located. 4. Optimization problems
This document is posted to help you gain knowledge. Please leave a comment to let me know what you think about it! Share it to your friends and learn new things together.
Transcript
Ye ☺☺☺☺ CS13002 Programming and Data
Structures
Spring
semester
Introduction
What is a digital computer?
A computer is a machine that can perform computation. It is difficult to give a precise
definition of computation. Intuitively, a computation involves the following three
components:
• Input: The user gives a set of input data.
• Processing: The input data is processed by a well-defined and finite sequence of
steps.
• Output: Some data available from the processing step are output to the user.
Usually, computations are carried out to solve some meaningful and useful problems.
One supplies some input instances for the problem, which are then analyzed in order to
obtain the answers for the instances.
Types of problems
1. Functional problems
A set of arguments a1,a2,...,an constitute the input. Some function f(a1,a2,...,an)
of the arguments is calculated and output to the user.
2. Decision problems
These form a special class of functional problems whose outputs are "yes" and
"no" (or "true" and "false", or "1" and "0", etc).
3. Search problems
Given an input object, one tries to locate some particular configuration pertaining
to the object and outputs the located configuration, or "failure" if no configuration
can be located.
4. Optimization problems
Given an object, a configuration and a criterion for goodness, one finds and
reports the configuration pertaining to the object, that is best with respect to the
goodness criterion. If no such configuration is found, "failure" is to be reported.
Specific examples
1. Polynomial root finding
Category: Functional problem
Input: A polynomial with real coefficients
Output: One (or all) real roots of the input polynomial
Processing: Usually, one involves a numerical method (like the Newton-Raphson
method) for computing the real roots of a polynomial.
2. Matrix inversion
Category: Functional problem
Input: A square matrix with rational entries
Output: The inverse of the input matrix if it is invertible, or "failure"
Processing: Gaussian elimination is a widely used method for matrix inversion.
Other techniques may also be conceived of.
3. Primality testing
Category: Decision problem
Input: A positive integer
Output: The decision whether the input integer is prime or not
Processing: For checking the primality of n, it is an obvious strategy to divide n
by integers between 2 and square root of n. If a divisor of n is found, n is declared
"composite" ("no"), else n is declared "prime" ("yes").
This obvious strategy is, however, very slow. More practical primality testing
algorithms are available. The first known (theoretically) fast algorithm is due to
three Indians (Agarwal, Kayal and Saxena) from IIT Kanpur.
4. Traveling salesman problem (TSP)
Category: Optimization problem
Input: A set of cities, the cost of traveling between each pair of cities, and the
criterion of cost minimization
Output: A route through all the cities with each city visited only once and with
the total cost of travel as small as possible
Processing: Since the total number of feasible routes for n cities is n!, a finite
quantity, checking all routes to find the minimum is definitely a strategy to solve
the TSP. However, n! grows very rapidly with n, and this brute-force search is
impractical. We do not know efficient solutions for the TSP. One may, however,
plan to remain happy with a suboptimal solution in which the total cost is not the
smallest possible, but close to it.
5. Weather prediction
Category: Functional problem
Input: Records of weather for previous days and years. Possibly also data from
satellites.
Output: Expected weather of Kharagpur for tomorrow
Processing: One statistically processes and analyzes the available data and makes
an educated extrapolating guess for tomorrow's weather.
6. Web browsing
Category: Functional problem
Input: A URL (abbreviation for "Uniform Resource Locator" which is
colloquially termed as "Internet site")
Output: Display (audio and visual) of the file at the given URL
Processing: Depending on the type of the file at the URL, one or more specific
programs are run and the desired output is generated. For example, a web browser
can render an HTML page, images in some formats etc. For displaying a movie, a
separate software (or its plug-in) need be employed.
7. Chess : Can I win?
Category: Search problem
Input: A configuration of the standard 8x8 chess board and the player ("white" or
"black") who is going to move next
Output: A winning move for the next player, if existent, or "failure"
Processing: In general, finding a winning chess move from a given state is a very
difficult problem. The trouble is that one may have to explore an infinite number
of possibilities. Even when the total possibilities are finite in number, that number
is so big that one cannot expect to complete exploration of all of these
possibilities in a reasonable time. A more practical strategy is to investigate all
possible board sequences involving a small number of moves starting from the
given configuration and to identify the best sequence under some criterion and
finally prescribe the first move in the best sequence.
A computer is a device that can solve these and similar problems. A digital computer
accepts, processes and outputs data in digitized forms (as opposed to analog forms).
• A computer is a fundamental discovery of human mind. It does not tend to mimic
other natural phenomena (except perhaps our brain).
• A computer can solve many problems. This is in sharp contrast with most other
engineering gadgets.
• Computers are programmable, i.e., one can solve one's own problems by a
computer.
The basic components of a digital computer
In order that a digital computer can solve problems, it should be equipped with the
following components:
• Input devices
These are the devices using which the user provides input instances. In a
programmable computer, input devices are also used to input programs.
Examples: keyboard, mouse.
• Output devices
These devices notify the user about the outputs of a computation. Example:
screen, printer.
• Processing unit
The central processing unit (CPU) is the brain of the computing device and
performs the basic processing steps. A CPU typically consists of:
o An arithmetic and logical unit (ALU): This provides the basic
operational units of the CPU. It is made up of units (like adders,
multipliers) that perform arithmetic operations on integers and real
numbers, and of units that perform logical operations (logical and bitwise
AND, OR etc.).
o A control unit: This unit is responsible for controlling flow of data and
instructions.
o General purpose registers: A CPU usually consists of a finite number of
memory cells that work as scratch locations for storing intermediate
results and values.
• External memory
The amount of memory (registers) resident in the CPU is typically very small and
is inadequate to accommodate programs and data even of small sizes. Out-of-the-
processor memory provides the desired storage space. External memory is
classified into two categories:
o Main (or primary) memory: This is a high-speed memory that stays
close to the CPU. Programs are first loaded in the main memory and then
executed. Usually main memory is volatile, i.e., its contents are lost after
power-down.
o Secondary memory: This is relatively inexpensive, bigger and low-speed
memory. It is normally meant for off-line storage, i.e., storage of programs
and data for future processing. One requires secondary storage to be
permanent, i.e., its contents should last even after shut-down. Examples of
secondary storage include floppy disks, hard disks and CDROM disks.
• Buses
A bus is a set of wires that connect the above components. Buses are responsible
for movement of data from input devices, to output devices and from/to CPU and
memory.
The interconnection diagram for a simple computer is shown in the figure below. This
architecture is commonly called the John von Neumann architecture after its
discoverer who was the first to give a concrete idea of stored program computers.
Surprisingly enough, the idea of computation (together with a rich theory behind it) was
proposed several decades earlier than the first real computer is manufactured. John von
Neumann proposed the first usable draft of a working computer.
Figure : The John von Neumann architecture
How does a program run in a computer?
The inputs, the intermediate values and the instructions defining the processing stage
reside in the (main) memory. In order to separate data from instructions the memory is
divided into two parts:
• Data area
The data area stores the variables needed for the processing stage. The values
stored in the data area can be read, written and modified by the CPU. The data
area is often divided into two parts: a stack part and a heap part. The stack part
typically holds all statically allocated memory (global and local variables),
whereas the heap part is used to allocate dynamic memory to programs during
run-time.
• Instruction area
The instruction area stores a sequence of instructions that define the steps of the
program. Under the control of a clock, the computer carries out a fetch-decode-
execute cycle in which instructions are fetched one-by-one from the instruction
area to the CPU, decoded in the control unit and executed in the ALU. The CPU
understands only a specific set of instructions. The instructions stored in memory
must conform to this specification.
The fetch-decode-execute cycle works as follows:
1. For starting the execution of a program, a sequence of machine instructions is
copied to the instruction area of the memory. Also some global variables and
input parameters are copied to the data area of the memory.
2. A particular control register, called the program counter (PC), is loaded with the
address of the first instruction of the program.
3. The CPU fetches the instruction from that location in the memory that is currently
stored in the PC register.
4. The instruction is decoded in the control unit of the CPU.
5. The instruction may require one or more operands. An operand may be either a
data or a memory address. A data may be either a constant (also called an
immediate operand) or a value stored in the data area of the memory or a value
stored in a register. Similarly, an address may be either immediate or a resident of
the main memory or available in a register.
6. An immediate operand is available from the instruction itself. The content of a
register is also available at the time of the execution of the instruction. Finally, a
variable value is fetched from the data part of the main memory.
7. If the instruction is a data movement operation, the corresponding movement is
performed. For example, a "load" instruction copies the data fetched from
memory to a register, whereas a "save" instruction sends a value from a register to
the data area of the memory.
8. If the instruction is an arithmetic or logical instruction, it is executed in the ALU
after all the operands are available in the CPU (in its registers). The output from
the ALU is stored back in a register.
9. If the instruction is a jump instruction, the instruction must contain a memory
address to jump to. The program counter (PC) is loaded with this address. A jump
may be conditional, i.e., the PC is loaded with the new address if and only if some
condition(s) is/are true.
10. If the instruction is not a jump instruction, the address stored in the PC is
incremented by one.
11. If the end of the program is not reached, the CPU goes to Step 3 and continues its
fetch-decode-execute cycle.
Figure : Execution of a program
Why need one program?
The electronic speed possessed by computers for processing data is really fabulous. Can
you imagine a human prodigy manually multiplying two thousand digit integers
flawlessly in an hour? A computer can perform that multiplication so fast that you even
do not perceive that it has taken any time at all. It is wise to exploit this instrument to the
best of our benefit. Why not, right?
However, there are many programs already written by professionals and amateurs. Why
need we bother about writing programs ourselves? If we have to find roots of a
polynomial or invert/multiply matrices or check primality of natural numbers, we can use
standard mathematical packages and libraries. If we want to do web browsing, it is not a
practical strategy that everyone writes his/her own browser. It is reported that playing
chess with the computer could be a really exciting experience, even to world champions
like Kasparov. Why should we write our own chess programs then? Thanks to the free
(and open-source) software movement, many useful programs are now available in the
public domain (often free of cost).
Still, we have to write programs ourselves! Here are some compelling reasons:
• There are so many problems to solve!
Simple counting arguments suggest that computers can solve infinitely many
problems. Given that the age of the universe and the human population are finite,
we cannot expect every problem to be solved by others. In other words, each of us
is expected to encounter problems which are so private that nobody else has even
bothered to solve them, let alone making the source-codes or executables freely
available for our consumption. Sometimes programs are available for solving
some of our problems, but these programs are either too costly to satisfy our
budget or so privately solved by others that they don't want to share their
programs with us. If we plan to harness the electronic speed of computers, there
seems to be no alternative way other than writing the programs ourselves.
A stupendous example is provided by the proof of the four color conjecture, a
curious mathematical problem that states that, given the map of any country, one
can always color the states of the country using only four colors in such a way
that no two states that share some boundary receive the same color. That five
colors are sufficient was known long back, but the four color conjecture remained
unsolved for quite a long time. Mathematicians reduced the problem to checking a
list of configurations. But the list was so huge that nobody could even think of
hand-calculating for all these instances. A computer program helped them explore
all these possibilities. The four color conjecture finally came out to be true.
Conservatives raised a huge hue and cry about such filthy methods of
mathematical problem solving. But a problem solved happens to be a problem
solved. Let the cynic cry!
Computers can aid you solving many problems of various flavors ranging from
mundane to practical to esoteric to deeply theoretical. Moreover, anybody may
benefit from programming computers, irrespective of his/her area of study. It's
just your own sweet will whether you plan to exploit this powerful servant.
• Hey, we can write better programs than them!
Yes, we often can. Available programs may be too general and we can solve
instances of our interest by specific programs much more efficiently than the
general jack-of-all-trades stuff. Moreover, you may occasionally come up with
brand-new algorithms that hold the promise of outperforming all previously
known algorithms. You would then desire to program your algorithms to see how
they perform in reality. Designing algorithms is (usually) a more difficult task
than programming the algorithms, but the two may often go hand-in-hand before
you jump to a practical conclusion.
How can one program?
Given a problem at hand, you tell the computer how to solve it and the machine does it.
Unfortunately, telling the computer your processing steps is not that easy. Computers can
be communicated with only in the language that they understand and are quite stubborn
about that.
You have to specify the exact way in which the fetch-decode-execute cycle is to be
carried out so that your problem is solved. The CPU of a computer supports a primitive
set of instructions (typically, data movement, arithmetic, logical and jump instructions).
Writing a program using these instructions (called assembly instructions) has two major
drawbacks:
• The assembly language is so low-level that writing a program in this language is a
very formidable task. One ends up with unmanageably huge codes that are very
error-prone and extremely difficult to debug and update.
• The assembly language varies from machines to machines. The assembly codes
suitable for one machine need not be understood by another machine. Moreover,
different machines support different types of assembly instructions and there is no
direct translation of instructions of one machine to those of another. For example,
the ALU of Computer A may support integer multiplication, whereas that of
Computer B does not. You have to translate each single multiplication instruction
for Computer A to your own routine (say, involving additions and shifts) for
doing multiplication in Computer B.
A high-level language helps you make your communication with computers more
abstract and simpler and also widely machine-independent. You then require computer
programs that convert your high-level description to the assembly-level descriptions of
individual machines, one program for each kind of CPU. Such a translator is called a
compiler.
Therefore, your problem solving with computers involves the following three steps:
1. Write the program in a high-level language
You should use a text editor to key in your program. In the laboratory we instruct
you to use the emacs editor. You should also save your program in a (named) file.
We are going to teach you the high-level language known as C.
2. Compile your program
You need a compiler to do that. In the lab you should use the C compiler cc
(a.k.a. gcc).
cc myprog.c
If your program compiles successfully, a file named a.out (an abbreviation of
"assembler output") is created. This file stores the machine instructions that can
be understood by the particular computer where you invoked the compiler.
If compilation fails, you should check your source code. The reason of the failure
is that you made one or more mistakes in specifying your idea. Compilers are
very stubborn about the syntax of your code. Even a single mistake may let the
compiler churn out many angry messages.
3. Run the machine executable file
This is done by typing
./a.out
(and then hitting the return/enter button) at the command prompt.
Your first C programs
• The file intro1.c
This program takes no input, but outputs the string "Hello, world!" in a line.
#include <stdio.h>
main ()
{
printf("Hello, world!\n");
}
• The file intro2.c
This program accepts an integer as input and outputs the same integer.
#include <stdio.h>
main ()
{
int n;
scanf("%d",&n);
printf("%d\n",n);
}
• The file intro3.c
This program takes an integer n as input and outputs its square n2.
#include <stdio.h>
main ()
{
int n;
scanf("%d",&n);
printf("%d\n",n*n);
}
• The file intro4.c
This program takes an integer n as input and is intended to compute its reciprocal
1/n.
#include <stdio.h>
main ()
{
int n;
scanf("%d",&n);
printf("%d\n",1/n);
}
Unfortunately, the program does not print the desired output. For input 0, it prints
"Floating exception". (Except for really esoteric situations, division by 0 is a
serious mathematical crime, so this was your punishment!) For input 1 it outputs
1, whereas for input -1 it outputs -1. For any other integer you input, the output is
0. That's too bad! But the accident is illustrating. Though your program compiled
gracefully and ran without hiccups, it didn't perform what you intended. This is
because you made few mistakes in specifying your desire.
• The file intro5.c
A corrected version of the reciprocal printer is as follows:
#include <stdio.h>
main ()
{
int n;
scanf("%d",&n);
printf("%f\n",1.0/n);
}
For input 67 it prints 0.014925, for input -32 it prints -0.031250. That's good
work! However, it reports 1.0/0 as "Inf" (infinity). Mathematicians may now turn
very angry because you didn't get the punishment you deserved.
Course home
CS13002 Programming and Data
Structures Spring
semester
Variables and simple data types
The first abstraction a high-level language (like C) offers is a way of structuring data. A machine's memory is a flat list of memory cells, each of a fixed size. The abstraction mechanism gives special interpretation to collections of cells. Think of a collection of blank papers glued (or stapled) together. A piece of blank paper is a piece of paper, after all. However, when you see the neatly bound object, you leap up in joy and assert, "Oh, that's my note book!" This is abstraction. Papers remain papers and their significance in a note book is in no way diminished. A special meaning of the collection is a thing that is rendered by the abstraction. There is another point here -- usage convenience. You would love to take class notes in a note book instead of in loose sheets. A note book is abstract in yet another sense. You call it a note book irrespective of the size and color of the papers, of whether there are built-in lines on the papers, of what material is used to manufacture the papers, etc.
The basic unit for storage of digital data is called a bit. It is an object that can assume one of the two possible values "0" and "1". Depending on how one is going to implement a bit, the values "0" and "1" are defined. If a capacitor stands for a bit, you may call its state "0" if the charge stored in it is less than 0.5 Volt, else you call its state "1". For a switch, "1" may mean "on" and "0" then means "off". Let us leave these implementation details to material scientists and VLSI designers. For us it is sufficient to assume that a computer comes with a memory having a huge number of built-in bits.
A single bit is too small a unit to be adequately useful. A collection of bits is what a practical unit for a computer's operation is. A byte (also called an octet) is a collection of eight bits. Bigger units are also often used. In many of today's computers data are transfered and processed in chunks of 32 bits (4 bytes). Such an operational unit is often called a word. Machines supporting 64-bit words are also coming up and are expected to replace 32-bit machines in near future.
Basic data types
Bytes (in fact, bits too) are abstractions. Still, they are pretty raw. We need to assign special meanings to collections of bits in order that we can use those collections to solve our problems. For example, a matrix inversion routine deals with matrices each of whose elements is a real (or rational or complex) number. We then somehow have to map memory contents to numbers, to matrices, to pairs of real numbers (complex numbers), and so on. Luckily enough, a programmer does not have to do this mapping himself/herself. The C compiler already provides the abstractions you require. It is the headache of the compiler how it would map your abstract entities to memory cells in your
machine. You, in your turn, must understand the abstraction level which is provided to you for writing programs in C.
For the time being, we will look at the basic data types supported by C. We will later see how these individual data types can be glued together to form more structured data types. Back to our note book example. A paper is already an abstraction, it's not any collection of electrons, protons and neutrons. So let us first understand what a paper is and what we can do with a piece of paper. We will later investigate how we can manufacture note books from papers, and racks from note books, and book-shelfs from racks, drawers, locks, keys and covers.
Integer data types
Integers are whole numbers that can assume both positive and negative values, i.e., elements of the set:
{ ..., -3, -2, -1, -, 1, 2, 3, ... }
This set is infinite, both the ellipses extending ad infinitum. C's built-in integer data types do not assume all possible integral values, but values between a minimum bound and a maximum bound. This is a pragmatic and historical definition of integers in C. The reason for these bounds is that C uses a fixed amount of memory for each individual integer. If that size is 32 bits, then only 232 integers can be represented, since each bit has only two possible states.
Integer data type Bit
size Minimum value Maximum value
char 8 -27=-128 27-1=127
short int 16 -215=-32768 215-1=32767
int 32 -231=-2147483648 231-1=2147483647
long int 32 -231=-2147483648 231-1=2147483647
long long int 64 -263=-9223372036854775808
263-1=9223372036854775807
unsigned char 8 0 28-1=255
unsigned short int 16 0 216-1=65535
unsigned int 32 0 232-1=4294967295
unsigned long int 32 0 232-1=4294967295
unsigned long long int 64 0 264-1=18446744073709551615
Notes
• The term int may be omitted in the long and short versions. For example, long
int can also be written as long, unsigned long long int also as unsigned
long long.
• ANSI C prescribes the exact size of int (and unsigned int) to be either 16 bytes
or 32 bytes, that is, an int is either a short int or a long int. Implementers decide which size they should select. Most modern compilers of today support 32-
bit int.
• The long long data type and its unsigned variant are not part of ANSI C specification. However, many compilers (including gcc) support these data types.
Float data types
Like integers, C provides representations of real numbers and those representations are finite. Depending on the size of the representation, C's real numbers have got different names.
Real data type Bit size
float 32
double 64
long double 128
Character data types
We need a way to express our thoughts in writing. This has been traditionally achieved by using an alphabet of symbols with each symbol representing a sound or a word or some punctuation or special mark. The computer also needs to communicate its findings to the user in the form of something written. Since the outputs are meant for human readers, it is advisable that the computer somehow translates its bit-wise world to a human-readable script. The Roman script (mistakenly also called the English script) is a natural candidate for the representation. The Roman alphabet consists of the lower-case letters (a to z), the upper case letters (A to Z), the numerals (0 through 9) and some punctuation symbols (period, comma, quotes etc.). In addition, computer developers planned for inclusion of some more control symbols (hash, caret, underscore etc.). Each such symbol is called a character.
In order to promote interoperability between different computers, some standard encoding scheme is adopted for the computer character set. This encoding is known as ASCII (abbreviation for American Standard Code for Information Interchange). In this scheme each character is assigned a unique integer value between 32 and 127. Since eight-bit units (bytes) are very common in a computer's internal data representation, the code of a character is represented by an 8-bit unit. Since an 8-bit unit can hold a total of 28=256 values and the computer character set is much smaller than that, some values of this 8-bit unit do not correspond to visible characters. These values are often used for representing invisible control characters (like line feed, alarm, tab etc.) and extended
Roman letters (inflected letters like ä, é, ç). Some values are reserved for possible future use. The ASCII encoding of the printable characters is summarized in the following table.
Decimal Hex Binary Character Decimal Hex Binary Character
32 20 00100000 SPACE 80 50 01010000 P
33 21 00100001 ! 81 51 01010001 Q
34 22 00100010 " 82 52 01010010 R
35 23 00100011 # 83 53 01010011 S
36 24 00100100 $ 84 54 01010100 T
37 25 00100101 % 85 55 01010101 U
38 26 00100110 & 86 56 01010110 V
39 27 00100111 ' 87 57 01010111 W
40 28 00101000 ( 88 58 01011000 X
41 29 00101001 ) 89 59 01011001 Y
42 2a 00101010 * 90 5a 01011010 Z
43 2b 00101011 + 91 5b 01011011 [
44 2c 00101100 , 92 5c 01011100 \
45 2d 00101101 - 93 5d 01011101 ]
46 2e 00101110 . 94 5e 01011110 ^
47 2f 00101111 / 95 5f 01011111 _
48 30 00110000 0 96 60 01100000 `
49 31 00110001 1 97 61 01100001 a
50 32 00110010 2 98 62 01100010 b
51 33 00110011 3 99 63 01100011 c
52 34 00110100 4 100 64 01100100 d
53 35 00110101 5 101 65 01100101 e
54 36 00110110 6 102 66 01100110 f
55 37 00110111 7 103 67 01100111 g
56 38 00111000 8 104 68 01101000 h
57 39 00111001 9 105 69 01101001 i
58 3a 00111010 : 106 6a 01101010 j
59 3b 00111011 ; 107 6b 01101011 k
60 3c 00111100 < 108 6c 01101100 l
61 3d 00111101 = 109 6d 01101101 m
62 3e 00111110 > 110 6e 01101110 n
63 3f 00111111 ? 111 6f 01101111 o
64 40 01000000 @ 112 70 01110000 p
65 41 01000001 A 113 71 01110001 q
66 42 01000010 B 114 72 01110010 r
67 43 01000011 C 115 73 01110011 s
68 44 01000100 D 116 74 01110100 t
69 45 01000101 E 117 75 01110101 u
70 46 01000110 F 118 76 01110110 v
71 47 01000111 G 119 77 01110111 w
72 48 01001000 H 120 78 01111000 x
73 49 01001001 I 121 79 01111001 y
74 4a 01001010 J 122 7a 01111010 z
75 4b 01001011 K 123 7b 01111011 {
76 4c 01001100 L 124 7c 01111100 |
77 4d 01001101 M 125 7d 01111101 }
78 4e 01001110 N 126 7e 01111110 ~
79 4f 01001111 O 127 7f 01111111 DELETE
Table : The ASCII values of the printable characters
C data types are necessary to represent characters. As told earlier, an eight-bit value suffices. The following two built-in data types are used for characters.
char unsigned char
Well, I mentioned earlier that these are integer data types. I continue to say so. These are
both integer and character data types. If you want to interpret a char value as a character, you see the character it represents. If you want to view it as an integer, you see the ASCII value of that character. For example, the upper case A has an ASCII value of 65. An eight-bit value representing the character A automatically represents the integer 65, because to the computer A is recognized by its ASCII code, not by its shape, geometry or sound!
Pointer data types
Pointers are addresses in memory. In order that the user can directly manipulate memory addresses, C provides an abstraction of addresses. The memory location where a data item resides can be accessed by a pointer to that particular data type. C uses the special
character * to declare pointer data types. A pointer to a double data is of data type
double *. A pointer to an unsigned long int data is of type unsigned long int *.
A character pointer has the data type char *. We will study pointers more elaborately later in this course.
Constants
Having defined data types is not sufficient. We need to work with specific instances of data of different types. Thus we are not much interested in defining an abstract class of
objects called integers. We need specific instances like 2, or -496, or +1234567890. We should not feel extravagantly elated just after being able to define an abstract entity called a house. We need one to live in.
Specific instances of data may be constants, i.e., values that do not change during the execution of programs. For example, the mathematical pi remains constant throughout every program, and expectedly throughout our life-time too. Similarly, when we wrote
1.0/n to compute reciprocals, we used the constant 1.0.
Constants are written much in the same way as they are written conventionally.
Integer constants
An integer constant is a non-empty sequence of decimal numbers preceded optionally by a sign (+ or -). However, the common practice of using commas to separate groups of three (or five) digits is not allowed in C. Nor are spaces or any character other than numerals allowed. Here are some valid integer constants:
332 -3002 +15 -00001020304
And here are some examples that C compilers do not accept:
3 332 2,334 - 456 2-34 12ab56cd
You can also express an integer in base 16, i.e., an integer in the hexadecimal
(abbreviated hex) notation. In that case you must write either 0x or 0X before the integer.
Hexadecimal representation requires 16 digits 0,1,...,15. In order to resolve ambiguities
the digits 10,11,12,13,14,15 are respectively denoted by a,b,c,d,e,f (or by
A,B,C,D,E,F). Here are some valid hexadecimal integer constants:
0x12ab56cd -0X123456 0xABCD1234 +0XaBCd12
Since different integer data types use different amounts of memory and represent different ranges of integers, it is often convenient to declare the intended data type explicitly. The following suffixes can be used for that:
Real constants can be specified by the usual notation comprising an optional sign, a decimal point and a sequence of digits. Like integers no other characters are allowed. Here are some specific examples:
1.23456 1. .1 -0.12345 +.4560
And here are some non-examples (invalid real constants):
. - 1.23 1 234.56 1,234.56 1.234.56
Real numbers are sometimes written in the scientific notation (like 3.45x1067). The following expressions are valid for writing a real number in this fashion: 3.45e67 +3.45e67 -3.45e-67 .00345e-32 1e-15
You can also use E in place of e in this notation.
Character constants
Character constants are single printable symbols enclosed within single quotes. Here are some examples:
'A' '7' '@' ' '
There are some special characters that require you to write more than one printable characters within the quotes. Here is a list of some of them:
Constant Character ASCII value
'\0' Null 0
'\b' Backspace 8
'\t' Tab 9
'\n' New line 13
'\'' Quote 39
'\\' Backslash 92
Since characters are identified with integers in the range -127 to 128 (or in the range 0 to 255), you can use integer constants in the prescribed range to denote characters. The
particular sequence '\xuv' (synonymous with 0xuv) lets you write a character in the hex
notation. (Here u and v are two hex digits.) For example, '\x2b' is the integer 43 in decimal notation and stands for the character '+'.
Pointer constants
Well, there are no pointer constants actually. It is dangerous to work with constant addresses. You may anyway use an integer as a constant address. But doing that lets the compiler issue you a warning message. Finally, when you run the program and try to access memory at a constant address, you are highly likely to encounter a frustrating mishap known as "Segmentation fault". That's a deadly enemy. Try to avoid it as and when you can!
Incidentally, there is a pointer constant that is used widely. This is called NULL. A NULL pointer points to nowhere.
Variables
Constants are not always sufficient to reflect reality. Though I am a constant human being and your constant PDS teacher, I am not a constant teacher for you or this classroom. Your or V1's teacher changes with time, though at any particular instant it assumes a constant value. A variable data is used to portray this scenario.
A variable is specified by a name given to a collection of memory locations. Named variables are useful from two considerations:
• Variables bind particular areas in the memory. You can access an area by a name and not by its explicit address. This abstraction simplifies a programmer's life dramatically. (If you want to tell a story to your friend about your pet, you would like to use its name instead of holding the constant object all the time in front of your friend's bored eyes.)
• Names promote parameterized computation. You change the value of a variable and obtain a different output. For example, the polynomial 2a2+3a-4 evaluates to different values, as you plug in different values for the variable a. Of course, the particular name a is symbolic here and can be replaced by any other name (b,c etc.), but the formal naming of the parameter allows you to write (and work with) the function symbolically.
Naming conventions
C does not allow any sequence of characters as the name of a variable. This kind of practice is not uncommon while naming human beings too. However, C's naming conventions are somewhat different from human conventions. To C, a legal name is any name prescribed by its rules. There is no question of aesthetics or meaning or sweet-sounding-ness.
You would probably not name your (would-be) baby as "123abc". C also does not allow this name. However, C allows the name "abc123". One usually does not see a human being with this name. But then, have you heard of "Louis XVI"?
Well, you may rack your brain for naming your baby. Here are C's straightforward rules.
• Any sequence of alphabetic characters (lower-case a to z and upper-case A to Z) and numerals (0 through 9) and underscore (_) can be a valid name, provided that:
o The name does not start with a numeral.
o The name does not coincide with one of C's reserved words (like double,
unsigned, for). These words have special meanings to the compilers and so are not allowed for your variables.
o The name does not coincide with the same name of another entity (declared in the same scope).
o The name does not contain any character other than those mentioned above.
• C's naming scheme is case-sensitive, i.e., teacher, Teacher, TEACHER, TeAcHeR are all different names.
• C does not impose any restriction on what name goes to what type of data. The
name fraction can be given to an int variable and the name submerged can be
given to a float variable. • There is no restriction on the minimum length (number of characters) of a name,
as long as the name is not the empty string. • Some compilers impose a restriction on the maximum length of a name. Names
bigger than this length are truncated to the maximum allowed leftmost part. This may lead to unwelcome collisions in different names. However, this upper limit is usually quite large, much larger than common names that we give to variables.
In C, names are given to other entities also, like functions, constants. In every case the above naming conventions must be adhered to.
Declaring variables
For declaring one or more variables of a given data type do the following:
• First write the data type of the variable. • Then put a space (or any other white character). • Then write your comma-separated list of variable names. • At the end put a semi-colon.
Here are some specific examples:
int m, n, armadillo; int platypus; float hi, goodMorning; unsigned char _u_the_charcoal;
You may also declare pointers simultaneously with other variables. All you have to do is to put an asterisk (*) before the name of each pointer.
long int counter, *pointer, *p, c; float *fptr, fval; double decker; double *standard;
Here counter and c are variables of type long int, whereas pointer and p are pointers
to data of type long int. Similarly, decker is a double variable, whereas standard is a
pointer to a double data.
Initializing variables
Once you declare a variable, the compiler allocates the requisite amount of memory to be accessed by the name of the variable. C does not make any attempt to fill that memory with any particular value. You have to do it explicitly. An uninitialized memory may contain any value (but it must contain some value) that may depend on several funny things like how long the computer slept after the previous shutdown, how much you have browsed the web before running your program, or may be even how much dust has accumulated on the case of your computer.
We will discuss in the next chapter how variables can be assigned specific values. For the time being, let us investigate the possibility that a variable can be initialized to a constant value at the time of its declaration. For achieving that you should put an equality sign immediately after the name followed by a constant value before closing the declaration by a comma or semicolon.
Here the variable dint is initialized to 0, mint to -32, whereas hint is not initialized. The
char pointer Romeo is not initialized, whereas Juliet is initialized to the NULL pointer.
Notice that uninitialized (and unassigned) variables may cause enough sufferings to a programmer. Take sufficient care!
Names of constants
So far we have used immediate constants that are defined and used in place. In order to reuse the same immediate constant at a different point in the program, the value must again be explicitly specified.
C provides facilities to name constant values (like variables). Here we discuss two ways of doing it.
Constant variables
Variables defined as above are read/write variables, i.e., one can both read their contents and store values in them. Constant variables are read-only variables and can be declared
by adding the reserved word const before the data type.
const double pi = 3.1415926535; const unsigned short int perfect1 = 6, perfect2 = 28, perfect3 = 496;
These declarations allocate space and initialize the variables like variable variables, but
don't allow the user to alter the value of PI, perfect1 etc. at a later time during the execution of the program.
#define'd constants
These are not variables. These are called macros. If you #define a value against a name and use that name elsewhere in your program, the name is literally substituted by the C preprocessor, before your code is compiled. Macros do not reside in the memory, but are expanded well before any allocation attempt is initiated.
Look at the differences with previous declarations. First, only one macro can be defined in a single line. Second, you do not need the semicolon or the equality sign for defining a macro.
Parameterized macros can also be defined, but unless you fully understand what a macro means and how parameters are handled in macros, don't use them. Just a wise tip, I believe! You can live without them.
Typecasting
An integer can naturally be identified with a real number. The converse is not immediate. However, we can adopt some convention regarding conversion of a real number to an integer. Two obvious candidates are truncation and rounding. C opts for truncation.
In order to convert a value <val> of any type to a value of <another_type> use the following directive:
(<another_type>)<val>
Here <val> may be a constant or a value stored in a named variable. In the examples
below we assume that piTo4 is a double variable that stores the value 97.4090910340.
Typecasting command Output value
(int)9.8696044011 The truncated integer 9
(int)-9.8696044011 The truncated integer -9
(float)9 The floating-point value 9.000000
(int)piTo4 The integer 97
(char)piTo4 The integer 97, or equivalently the character 'a'
(int *)piTo4 An integer pointer that points to the memory location 97.
(double)piTo4 The same value stored in piTo4
Typecasting also applies to expressions and values returned by functions.
Representation of numbers in memory
Binary representation
Computer's world is binary. Each computation involves manipulating a series of bits each realized by some mechanism that can have two possible states denoted "0" and "1". If that is the case, integers, characters, floating point numbers need also be represented by bits. Here is how this representation can be performed.
For us, on the other hand, it is customary to have 10 digits in our two hands and consequently 10 digits in a number system. The decimal system is natural. Not really, it is just the convention. From our childhood we have been taught to use base ten representations to such an extent that it is difficult to conceive of alternatives, in fact to even think that any natural number greater than 1 can be a legal base for number
representation. (There also exists an "exponentially big" unary representation of numbers that uses only one digit better called a "symbol" now.)
Binary expansion of integers
Let's first take the case of non-negative integers. In order to convert such an integer n from the decimal representation to the binary representation, one keeps on dividing n by 2 and remembering the intermediate remainders obtained. When n becomes 0, we have to write the remainders in the reverse sequence as they are generated. That's the original n in binary.
n Remainder
57
Divide by 2 28 1
Divide by 2 14 0
Divide by 2 7 0
Divide by 2 3 1
Divide by 2 1 1
Divide by 2 0 1
57 = (111001)2
For computers, we usually also specify a size t of the binary representation. For example,
suppose we want to represent 57 as an unsigned char, i.e., as an 8-bit value. The above algorithm works fine, but we have to
• either insert the requisite number of leading zero bits, • or repeat the "divide by 2" step exactly t times without ever looking at whether
the quotient has become 0.
n Remainder
57
Divide by 2 28 1
Divide by 2 14 0
Divide by 2 7 0
Divide by 2 3 1
Divide by 2 1 1
Divide by 2 0 1
Divide by 2 0 0
Divide by 2 0 0
57 = (00111001)2
What if the given n is too big to fit in a t-bit place? Now also you can "divide by 2" exactly t times and read the t remainders backward. That will give you the least significant t bits of n. The remaining more significant bits will simply be ignored.
n Remainder
657
Divide by 2 328 1
Divide by 2 164 0
Divide by 2 82 0
Divide by 2 41 0
Divide by 2 20 1
Divide by 2 10 0
Divide by 2 5 0
Divide by 2 2 1
657 = (...10010001)2
Signed magnitude representation of integers
Now we add provision for sign. Here is how this is conventionally done. In a t-bit signed representation of n:
• The most significant (leftmost) bit is reserved for the sign. "0" means positive, "1" means negative.
• The remaining t-1 bits store the (t-1)-bit representation of the magnitude (absolute value) of n (i.e., of |n|).
Example: The 7-bit binary representation of 57 is (0111001)2.
• The 8-bit signed magnitude representation of 57 is (00111001)2. • The 8-bit signed magnitude representation of -57 is (10111001)2.
Back to decimal
Given an integer in unsigned or signed representation, its magnitude and sign can be determined. For the sign, the most significant bit is consulted. For the magnitude, a sum of appropriate powers of 2 is calculated.
Let the magnitude be stored in l bits. The bits are numbered 0,1,...,l-1 from right to left. The i-th position (from the right) corresponds to the power 2i. One simply adds the powers of 2 corresponding to those positions that hold 1 bits in the binary representation.
Signed integer 0 0 1 1 1 0 0 1
Position Sign 6 5 4 3 2 1 0
Contribution + 0 25 24 23 0 0 20
+(25+24+23+20) = +(32+16+8+1) = +57
Signed integer 1 0 0 1 0 0 0 1
Position Sign 6 5 4 3 2 1 0
Contribution - 0 0 24 0 0 0 20
-(24+20) = -(16+1) = -17
Unsigned integer 1 0 0 1 0 0 0 1
Position 7 6 5 4 3 2 1 0
Contribution 27 0 0 24 0 0 0 20
27+24+20 = 128+16+1 = 145
Notes:
• The t-bit unsigned representation can accommodate integers in the range 0 to 2t-1. • The t-bit signed magnitude representation can accommodate integers in the range
-(2t-1-1) to +(2t-1-1).
• In the signed magnitude representation 0 has two renderings: +0 = 0000...0 and
-0=1000...0.
1's complement representation
1's complement of a t-bit sequence (at-1at-2...a0)2 is the t-bit sequence (bt-1bt-2...b0)2, where for each i we have bi = 1 - ai, i.e., bi is the bit-wise complement of ai. Here (bt-1bt-
2...b0)2= 2t-1-(at-1at-2...a0)2.
The t-bit 1's complement representation of an integer n is a t-bit signed representation with the following properties:
• The most significant (leftmost) bit is the sign bit, 0 if n is positive, 1 if n is negative.
• The remaining t-1 bits are used to stand for the absolute value |n|. o If n is positive, these t-1 bits hold the (t-1)-bit binary representation of |n|. o If n is negative, these t-1 bits hold the (t-1)-bit 1's complement of |n|.
Example: The 7-bit binary representation of 57 is (0111001)2. The 7-bit 1's complement of 57 is (1000110)2.
• The 1's complement representation of +57 is (00111001)2. • The 1's complement representation of -57 is (11000110)2.
Notes:
• The t-bit 1's complement representation can accommodate integers in the range -(2t-1-1) to +(2t-1-1).
• 0 has two representations: +0 = (0000...0)2 and -0 = (1111...1)2.
2's complement representation
The t-bit 2's complement of a positive integer n is 1 plus the t-bit 1's complement of n. Thus one first complements each bit in the t-bit binary expansion of n, and then adds 1 to
this complemented number. If n = (at-1at-2...a0)2, then its t-bit 1's complement is (bt-1bt-
2...b0)2 with each bi = 1 - ai, and therefore the 2's complement of n is n' = 1+(bt-1bt-
2...b0)2 = 1+(2t-1)-n = 2t-n. In order that n' fits in t-bits we then require 0<=n'<=2t-1,
i.e., 1<=n<=2t.
The t-bit 2's complement representation of an integer n is a t-bit signed representation with the following properties:
• The most significant (leftmost) bit is the sign bit, 0 if n is positive, 1 if n is negative.
• The remaining t-1 bits are used to stand for the absolute value |n|. o If n is positive, these t-1 bits hold the (t-1)-bit binary representation of |n|. o If n is negative, these t-1 bits hold the (t-1)-bit 2's complement of |n|.
Example: The 7-bit binary representation of 57 is (0111001)2. The 7-bit 1's complement of 57 is (1000110)2, so the 7-bit 2's complement of 57 is (1000111)2.
• The 2's complement representation of +57 is (00111001)2. • The 2's complement representation of -57 is (11000111)2.
Notes:
• The t-bit 2's complement representation can accommodate integers in the range -2t-1 to +(2t-1-1).
• 0 has only one representation: (0000...0)2. • The 2's complement representation simplifies implementation of arithmetic (in
hardware).
Example: The different 8-bit representations of signed integers are summarized in the following table:
Decimal Signed
magnitude
1's
complement
2's
complement
+127 01111111 01111111 01111111
+126 01111110 01111110 01111110
+125 01111101 01111101 01111101
... ... ... ...
+3 00000011 00000011 00000011
+2 00000010 00000010 00000010
+1 00000001 00000001 00000001
0 00000000
or 10000000
00000000 or
11111111 00000000
-1 10000001 11111110 11111111
-2 10000010 11111101 11111110
-3 10000011 11111100 11111101
... ... ... ...
-126 01111110 10000001 10000010
-127 01111111 10000000 10000001
-128 No rep No rep 10000000
Hexadecimal and octal representations
Similar to binary (base 2) representation, one can have representations of integers in any base B>=2. In computer science two popular bases are 16 and 8. The representation of an integer in base 16 is called the hexadecimal representation, whereas that in base 8 is called the octal representation of the integer.
For any base B, the base B representation of n can be obtained by successively dividing n by B until the quotient becomes zero. One then writes the remainders in the reverse sequence as they are generated. Since division by B leaves remainders in the range
0,1,...,B-1, one requires these many digits for the base B representation. If B=8, the
natural (octal) digits are 0,1,...,7. For B=16, we have a problem; we now require 16
digits 0,1,...,15. Now it is difficult to distinguish, for example, between 13 as a digit and 13 as the digit 1 followed by the digit 3. We use the symbols a,b,c,d,e,f (also in upper case) to stand for the hexadecimal digits 10,11,12,13,14,15.
Example: Hexadecimal representation
n Remainder
413657
Divide by 16 25853 9
Divide by 16 1615 13
Divide by 16 100 15
Divide by 16 6 4
Divide by 16 0 6
413657 = 0x64fd9
Example: Octal representation
n Remainder
413657
Divide by 8 51707 1
Divide by 8 6463 3
Divide by 8 807 7
Divide by 8 100 7
Divide by 8 12 4
Divide by 8 1 4
Divide by 8 0 1
413657 = (1447731)8
Since 16 and 8 are powers of two, the hexadecimal and octal representations of an integer can also be computed from its binary representation. For the hexadecimal representation, one generates groups of successive 4 bits starting from the right of the binary representation. One may have to add a requisite number of leading 0 bits in order to make the leftmost group contain 4 bits. One 4 bit integer corresponds to an integer in the range
0,1,...,15, i.e., to a hexadecimal digit. For the octal representation, grouping should be made three bits at a time.
Example: The binary representation of 413657 is (1100100111111011001)2. Arranging this bit-sequence in groups of 4 gives:
110 0100 1111 1101 1001
Thus 413657 = 0x64fd9, as calculated above.
The grouping with three bits per group is:
1 100 100 111 111 011 001
Thus 413657 = (1447731)8.
IEEE floating point standard
Now it's time for representing real numbers in binary. Let us first review our decimal intuition. Think of the real number:
n = 172.93 = 1.7293 x 102 = 0.17293 x 103
By successive division by 2 we can represent the integer part 172 of n in binary. For the fractional part 0.93 we use repeated multiplication by two in order to get the bits after the binary point. After each multiplication, the integer part of the product generates the next bit in the representation. We then replace the old fractional part by the fractional part of the product.
Integral
part
Remain
der
172
Divide by 2
86 0
Divide by 2
43 0
Divide by 2
21 1
Divide by 2
10 1
Divide by 2
5 0
Divide by 2
2 1
Divide by 2
1 0
Divide by 2
0 1
172 = (10101100)2
Fractional
part
Integral
part
0.93
Multiply by 2
0.86 1
Multiply by 2
0.72 1
Multiply by 2
0.44 1
Multiply by 2
0.88 0
Multiply by 2
0.76 1
Multiply by 2
0.52 1
Multiply by 2
0.04 1
Multiply by 2
0.08 0
Multiply by 2
0.16 0
Multiply by 2
0.32 0
0.93 = (0.1110111000...
)2
172.93 = (10101100.1110111000...)2 = (1.01011001110111000...)2 x 27 = (0.1010110
01110111000...)2 x 28
It turns out that the decimal fraction 0.93 does not have a terminating binary expansion. So we have to approximate the binary expansion (after the binary point) by truncating the series after a predefined number of bits. Truncating after ten bits gives the approximate value of n to be:
This example illustrates how to store approximate representations of real numbers using a fixed amount of bits. If we write the expansion in the normal form with only one 1 bit (and nothing else) to the left of the binary point, then it is sufficient to store only the fractional part (0101100111 in our example) and the exponent of 2 (7 in the example). This is precisely what is done by the IEEE 754 floating-point format. This is a 32-bit representation of signed floating point numbers. The 32 bits are used as follows:
31 30 29 ... 24 23 22 21 ... 1 0
S E7 E6 ... E1 E0 M22 M21 ... M1 M0
The meanings of the different parts are as follows:
• S is the sign bit, 0 represents positive, and 1 negative.
• The eight bits E7E6...E1E0 represent the exponent. For usual numbers it is allowed to lie in the range 1 to 254.
• The rightmost 23 bits M22M21...M1M0 represent the mantissa (also called significand). It is allowed to take any of the 223 values between 0 and 223-1.
Normal numbers
The normal number that this 32-bit value stores is interpreted as:
(-1)S x (1.M22M21...M1M0)2 x 2[(E
7E6...E
1E0)2-127]
The biggest real number that this representation stores corresponds to
0 11111110 1111111 11111111 11111111
which is approximately 2128, i.e., 3.403 x 1038. The smallest positive value that this format can store corresponds to 0 00000001 0000000 00000000 00000000
which is 1.00000000000000000000000 x 2-126 = 2-126,
i.e., nearly 1.175 x 10-38.
Denormal numbers
The IEEE standard also supports a denormal form. Now all the exponent bits
E7E6...E1E0 must be 0. The 32-bit value is now interpreted as the number:
(-1)S x (0.M22M21...M1M0)2 x 2-126
The maximum positive value that can be represented by the denormal form corresponds to
0 00000000 1111111 11111111 11111111
which is
0.11111111111111111111111 x 2-126 = 2-126 - 2-149.
This is obtained by subtracting 1 from the least significant bit position of the smallest positive integer representable by a normal number. Denormal numbers therefore correspond to a gradual underflow from normal numbers.
The minimum positive value that can be represented by the denormal form corresponds to
0 00000000 0000000 00000000 00000001
which is 2-149, i.e., nearly 1.401 x 10-45.
Special numbers
Recall that the exponent bits were not allowed to take the value 1111 1111 (255 in decimal). This value corresponds to some special numbers. These numbers together with some other special ones are listed in the following table.
Arrays are our first example of structured data. Think of a book with pages numbered
1,2,...,400. The book is a single entity, has its individual name, author(s), publisher, bla bla bla, but the contents of its different pages are (normally) different. Moreover, Page 251 of the book refers to a particular page of the book. To sum up, individual pages retain their identities and still we have a special handy bound structure treated as a single entity. That's again abstraction, but this course is mostly about that.
Now imagine that you plan to sum 400 integers. Where will you store the individual integers? Thanks to your ability to declare variables, you can certainly do that. Declare 400 variables with 400 different names, initialize them individually and finally add each variable separately to an accumulating sum. That's gigantic code just for a small task.
Arrays are there to help you. Like your book you now have a single name for an entire collection of 400 integers. Declaration is small. Codes for initialization and addition also become shorter, because you can now access the different elements of the collection by a unique index. There are built-in C constructs that allow you do parameterized (i.e., indexed) tasks repetitively.
Declaring arrays
Simple! Just as you did for individual data items, write the data type, then a (legal) name and immediately after that the size of the array within square brackets. For example, the declaration
int intHerd[400];
creates an array of name intHerd that is capable of storing 400 int data. A more stylistic way to do the same is illustrated now.
#define HERD_SIZE 400 int intHerd[HERD_SIZE];
Here are two other arrays, the first containing 123 float data, the second 1024 unsigned
char data.
float flock[123]; unsigned char crowd[1024];
You can intersperse declaration of arrays with those of simple variables and pointers.
unsigned long man, society[100], woman, *ptr;
This creates space for two unsigned long variables man and woman, an array called
society with hundred unsigned long data, and also a pointer named ptr to an
unsigned long data.
Note that all individual elements of a single array must be of the same type. You cannot declare an array some of whose elements are integers, the rest floating-point numbers. Such heterogeneous collections can be defined by other means that we will introduce later.
Accessing individual array elements
Once an array A of size s is declared, its individual elements are accessed as
A[0],A[1],...,A[s-1]. It is very important to note that:
Array indexing in C is zero-based.
This means that the "first" element of A is named as A[0] (not A[1]), the "second" as
A[1], and so on. The last element is A[s-1].
Each element A[i] is of data type as provided in the declaration. For example, if the declaration goes as:
int A[32];
each of the elements A[0],A[1],...,A[31] is a variable of type int. You can do on
each A[i] whatever you are allowed to do on a single int variable.
C does not provide automatic range checking.
If an array A of size s is declared, the element A[i] belongs to the array (more correctly, to the memory locations allocated to A) if and only if 0 <= i <= s-1. However, you can
use A[i] for other values of i. No compilation errors (nor warnings) are generated for that. Now when you run the program, the executable attempts to access a part of the memory that is not allocated to your array, nor perhaps to (the data area allocated to) your program at all. You simply do not know what resides in that part of the memory. Moreover, illegal memory access may lead to the deadly "segmentation fault". C is too cruel at certain points. Beware of that!
Initializing arrays
Arrays can be initialized during declaration. For that you have to specify constant values for its elements. The list of initializing values should be enclosed in curly braces. For the declaration
int A[5] = { 51, 29, 0, -34, 67 };
A[0] is initialized to 51, A[1] to 29, A[2] to 0, A[3] to -34 and A[4] to 67. Similarly, for the declaration
C[0] gets the value 'a', C[1] the value 'b', and so on. The last (7th) location receives the null character. Such null-terminated character arrays are also called strings. Strings can be initialized in an alternative way. The last declaration is equivalent to:
char C[8] = "abhijit";
Now see that the trailing null character is missing here. C automatically puts it at the end. Note also that for individual characters, C uses single quotes, whereas for strings, it uses double quotes.
If you do not mention sufficiently many initial values to populate an entire array, C uses your incomplete list to initialize the array locations at the lower end (starting from 0). The remaining locations are initialized to zero. For example, the initialization
int A[5] = { 51, 29 };
is equivalent to
int A[5] = { 51, 29, 0, 0, 0 };
If you specify an initialization list, you may omit the size of the array. In that case, the array will be allocated exactly as much space as is necessary to accommodate the initialization list. You must, however, provide the square brackets to indicate that you are declaring an array; the size may be missing between them.
int A[] = { 51, 29 };
creates an array A of size 2 with A[0] holding the value 51 and A[1] the value 29. This declaration is equivalent to
int A[2] = { 51, 29 };
but not to
int A[5] = { 51, 29 };
There are a lot more things that pertain to arrays. You may declare multi-dimensional arrays, you may often interchange arrays with pointers, and so on. But it's now too early for these classified topics. Wait until your experience with C ripens.
Course home
CS13002 Programming and Data
Structures Spring
semester
Assignments
Assignments and imperative programming
Initialization during declaration helps one store constant values in memory allocated to
variables. Later one typically does a sequence of the following:
• Read the values stored in variables.
• Do some operations on these values.
• Store the result back in some variable.
This three-stage process is effected by an assignment operation. A generic assignment
operation looks like: variable = expression;
Here expression consists of variables and constants combined using arithmetic and
logical operators. The equality sign (=) is the assignment operator. To the left of this
operator resides the name of a variable. All the variables present in expression are
loaded to the CPU. The ALU then evaluates the expression on these values. The final
result is stored in the location allocated to variable. The semicolon at the end is
mandatory and denotes that the particular statement is over. It is a statement delimiter,
not a statement separator.
Animation example : expression evaluation
A C program typically consists of a sequence of statements. They are executed one-by-
one from top to bottom (unless some explicit jump instruction or function call is
encountered). This sequential execution of statements gives C a distinctive imperative
flavor. This means that the sequence in which statements are executed decides the final
values stored in variables. Let us illustrate this using an example:
int x = 43, y = 15; /* Two integer variables are declared and
initialized */
x = y + 5; /* The value 15 of y is fetched and added to 5.
The sum 20 is stored in the memory location for x. */
y = x; /* The value stored in x, i.e., 20 is fetched and stored
back in y. */
After these statements are executed both the memory locations for x and y store the
integer value 20.
Let us now switch the two assignment operations.
int x = 43, y = 15; /* Two integer variables are declared and
initialized */
y = x; /* The value stored in x, i.e., 43 is fetched and stored
back in y. */
x = y + 5; /* The value 43 of y is fetched and added to 5.
The sum 48 is stored in the memory location for x. */
For this sequence, x stores the value 48 and y the value 43, after the two assignment
statements are executed.
The right side of an assignment operation may contain multiple occurrences of the same
variable. For each such occurrence the same value stored in the variable is substituted.
Moreover, the variable in the left side of the assignment operator may appear in the right
side too. In that case, each occurrence in the right side refers to the older (pre-
assignment) value of the variable. After the expression is evaluated, the value of the
variable is updated by the result of the evaluation. For example, consider the following:
int x = 5;
x = x + (x * x);
The value 5 stored in x is substituted for each occurrence of x in the right side, i.e., the
expression 5 + (5 * 5) is evaluated. The result is 30 and is stored back to x. Thus, this
assignment operation causes the value of x to change from 5 to 30. The equality sign in
the assignment statement is not a mathematical equality, i.e., the above statement does
not refer to the equation x = x + x2 (which happens to have a single root, namely
x = 0). It similarly makes sense to write
z = z + 2;
to imply an assignment (increment the value of z by 2). Mathematically, it makes little
sense, since no numbers you know seem to satisfy the equation z = z + 2. (But I know
some of them!) Notice that in C there is a different way for checking equality of two
expressions. The single equality sign is not that.
Floating point numbers, characters and array locations may also be used in assignment
operations.
float a = 2.3456, b = 6.5432, c[5]; /* Declare float variables and
arrays */
char d, e[4]; /* Declare character variables
and arrays */
c[0] = a + b; /* c[0] is assigned 2.3456 + 6.5432, i.e.,
8.8888 */
c[1] = a - c[0]; /* c[1] is assigned 2.3456 - 8.8888, i.e., -
6.5432 */
c[2] = b - c[0]; /* c[2] is assigned 6.5432 - 8.8888, i.e., -
2.3456 */
a = c[1] + c[2]; /* a is assigned (-6.5432) + (-2.3456), i.e.,
-8.8888 */
d = 'A' - 1; /* d is assigned the character ('@') one less
than 'A' in the ASCII chart */
e[0] = d + 1; /* e[0] is assigned the character next to '@',
i.e., 'A' */
e[1] = e[0] + 1; /* e[1] is assigned the character next to 'A',
i.e., 'B' */
e[2] = e[0] + 2; /* e[2] is assigned the character second next
to 'A', i.e., 'C' */
e[3] = e[2] + 1; /* e[3] is assigned the character next to 'C',
i.e., 'D' */
An assignment does an implicit type conversion, if its left side turns out to be of a
different data type than the type of the expression evaluated.
float a = 7.89, b = 3.21;
int c;
c = a + b;
Here the right side involves the floating point operation 7.89 + 3.21. The result is the
floating point value 11.1. The assignment plans to store this result in an integer variable.
The value 11.1 is first truncated and subsequently the integer value 11 is stored in c. One
can explicitly mention this typecasting command as:
float a = 7.89, b = 3.21;
int c;
c = (int)(a + b);
The parentheses around the expression a + b implies that the typecasting is to be done
after the evaluation of the expression. The following variant has a different effect:
float a = 7.89, b = 3.21;
int c;
c = (int)a + b;
Here a is first converted to 7 and then added to 3.21. The resulting value (10.21) is
truncated and stored in c. That is, now c is assigned the value 10.
In C, an assignment operation also returns a value. It is precisely the value that is
assigned. This value can again be used in an expression.
int a, b, c;
c = (a = 8) + (b = 13);
Here a is assigned the value 8 and b the value 13. The values (8 and 13) returned by these
assignments are then added and the sum 21 is stored in c. The assignment of c also
returns a value, i.e., 21. Here we have ignored this value. Assignment is right associative.
For example,
a = b = c = 0;
is equivalent to
a = (b = (c = 0));
Here c is first assigned the value 0. This value is returned to assign b, i.e., b also gets the
value 0. The value returned from this second assignment is then assigned to a. Thus after
this statement all of a, b and c are assigned the value 0.
Built-in operators
Now that we know how to assign values to variables, what remains is a discussion on
how expressions can be generated. Here are the rules:
• A constant is an expression.
• A (defined) variable is an expression.
• If E is an expression, then so also is (E).
• If E is an expression and op a unary operator defined in C, then op E is again an
expression.
• If E1 and E2 are expressions and op is a binary operator defined in C, then
E1 op E2 is again an expression.
• If V is a variable and E is an expression, then V = E is also an expression.
These rules do not exhaust all possibilities for generating expressions, but form a handy
set to start with.
Examples:
53 /* constant */
-3.21 /* constant */
'a' /* constant */
x /* variable */
-x[0] /* unary negation on a variable */
x + 5 /* addition of two subexpressions */
(x + 5) /* parenthesized expression */
(x) + (((5))) /* another parenthesized expression */
y[78] / (x + 5) /* more complex expression */
y[78] / x + 5 /* another complex expression */
y / (x = 5) /* expression involving assignment */
1 + 32.5 / 'a' /* expression involving different data types */
Non-examples:
5 3 /* space is not an operator and integer
constants may not contain spaces */
y *+ 5 /* *+ is not a defined operator */
x (+ 5) /* badly placed parentheses */
x = 5; /* semi-colons are not allowed in expressions */
We now list the basic operators defined in C and the interpretations of these operators.
Arithmetic operators
Arithmetic operators include negation, addition, subtraction, multiplication and division.
The result of the operation depends on which type of data the arithmetic operator operates
on. The following table summarizes the relevant information.
Operator Meaning Description
- unary
negation
Applicable for integers and real numbers. Does not make
enough sense for unsigned operands.
+ (binary)
addition Applicable for integers and real numbers.
- (binary)
subtraction Applicable for integers and real numbers.
* (binary)
multiplication Applicable for integers and real numbers.
/ (binary)
division
For integers division means "quotient", whereas for real
numbers division means "real division". If both the operands
are integers, the integer quotient is calculated, whereas if (one
or both) the operands are real numbers, real division is carried
out.
% (binary)
remainder Applicable only for integer operands.
Examples: Here are examples of integer arithmetic:
55 + 21 evaluates to 76.
55 - 21 evaluates to 34.
55 * 21 evaluates to 1155.
55 / 21 evaluates to 2.
55 % 21 evaluates to 13.
Here are some examples of floating point arithmetic:
55.0 + 21.0 evaluates to 76.0.
55.0 - 21.0 evaluates to 34.0.
55.0 * 21.0 evaluates to 1155.0.
55.0 / 21.0 evaluates to 2.6190476 (approximately).
55.0 % 21.0 is not defined.
Note: C does not provide a built-in exponentiation operator.
Bitwise operators
Bitwise operations apply to unsigned integer operands and work on each individual bit.
Bitwise operations on signed integers give results that depend on the compiler used, and
so are not recommended in good programs. The following table summarizes the bitwise
operations. For illustration we use two unsigned char operands a and b. We assume
that a stores the value 237 = (11101101)2 and that b stores the value 174 = (10101110)2.
Operator Meaning Example
& AND
a = 237 1 1 1 0 1 1 0 1
b = 174 1 0 1 0 1 1 1 0
a & b is 172 1 0 1 0 1 1 0 0
| OR
a = 237 1 1 1 0 1 1 0 1
b = 174 1 0 1 0 1 1 1 0
a | b is 239 1 1 1 0 1 1 1 1
^ EXOR
a = 237 1 1 1 0 1 1 0 1
b = 174 1 0 1 0 1 1 1 0
a ^ b is 67 0 1 0 0 0 0 1 1
~ Complement a = 237 1 1 1 0 1 1 0 1
~a is 18 0 0 0 1 0 0 1 0
>> Right-shift a = 237 1 1 1 0 1 1 0 1
a >> 2 is 59 0 0 1 1 1 0 1 1
<< Left-shift b = 174 1 0 1 0 1 1 1 0
b << 1 is 92 0 1 0 1 1 1 0 0
Some shorthand notations
C provides some shorthand notations for some particular kinds of operations. For
example, if the variable to be assigned is the first operand in the expression on the right
side, then this variable may be omitted in the expression and the operator comes before
the equality sign. More precisely, the assignment
var = var op expression;
is equivalent to
var op= expression;
Here the operator op can be any binary operator described above, namely, +,-
,*,/,%,&,|,^,>>,<<. Some specific examples are:
a = a + 10.43; is equivalent to a += 10.43;
a = a % 43; is equivalent to a %= 43;
c = c * (a + b - c); is equivalent to c *= a + b - c;
a = a >> 3; is equivalent to a >>= 3;
b = b ^ (a << 3); is equivalent to b ^= (a << 3);
A special case of this can be shortened further: increment/decrement by 1.
a = a + 1; is equivalent to a += 1; which is also equivalent to ++a;
b = b - 1; is equivalent to b -= 1; which is also equivalent to --b;
These increment/decrement operators (++ and --) are called pre-increment and pre-
decrement operators. C also provides post-increment and post-decrement operators.
These operators are same (++ and --) but are written after the variable being
incremented/decremented. The isolated statements
a++;
b--;
are respectively equivalent to
++a;
--b;
However, there is a subtle difference between the two. Recall that every assignment
returns a value. The increment (or decrement) expressions ++a and a++ are also
assignment expressions. Both stand for "increment the value of a by 1". But then which
value of a is returned by this expression? We have the following rules:
• For a++ the older value of a is returned and then the value of a is incremented.
This is why it is called the post-increment operation.
• For ++a the value of a is first incremented and this new (incremented) value of a
is returned. This is why it is called the pre-increment operation.
A similar argument holds for the decrement operations. The following examples illustrate
the differences:
a = 43;
b = 15;
c = (++a) * (--b);
Here a is first incremented and the value 44 is returned. Also b is decremented and the
value 14 is returned. Then these two values are multiplied and the product 44*14 = 616 is
assigned to c.
a = 43;
b = 15;
c = (++a) * (b--);
Now a is first incremented and the value 44 is returned. But the value of b is first
returned (15) and then decremented. Thus c gets the value 44*15 = 660. Similarly, after
the execution of the following statements
a = 43;
b = 15;
c = (a++) * (b--);
a, b and c respectively hold the values 44, 14 and 43*15 = 645.
Precedence of operators
An explicitly parenthesized arithmetic (and/or logical) expression clearly indicates the
sequence of operations to be performed on its arguments. However, it is quite common
that we do not write all the parentheses in such expressions. Instead, we use some rules of
precedence and associativity, that make the sequence clear. For example, the expression
a + b * c
conventionally stands for
a + (b * c)
and not for
(a + b) * c
The reason is that the multiplication operator has higher precedence than the addition
operator. This means that * attracts the common operand b more forcibly than + does. As
a result, b becomes an operand for * and not for +. Note that in general these two
expressions evaluate to different values. For example, 40 + (15 * 7) equals 145, whereas
(40 + 15) * 7 evaluates to 385. It is, therefore, necessary that when we write 40 + 15 * 7,
we precisely understand which way we plan to resolve the ambiguity.
In order to explain another source of ambiguity, let us look at the expression
a - b - c
Now the common operand b belongs to two same operators (subtraction). They have the
same precedence. Now we can evaluate this as
(a - b) - c
or as
a - (b - c)
Again the two expressions may evaluate to different values. For example, (40 - 15) - 7 is
18, whereas 40 - (15 - 7) is 32. The convention is that the first interpretation is correct. In
other words, the subtraction operator is left-associative.
C is no exception to these conventional interpretations. You need not fully parenthesize a
composite expression. C applies the standard precedence rules for evaluating the
expression. The following table describes the precedence and associativity rules for all
the arithmetic and bitwise operators introduced so far. The table lists operators from
higher to lower precedences, i.e., operators at later rows have lower precedences than
operators at earlier rows.
Operator(s) Type Associativity
++ -- unary non-associative
- ~ unary right
* / % binary left
+ - binary left
<< >> binary left
& binary left
| ^ binary left
= += -= *= etc. binary right
Course home
CS13002 Programming and Data
Structures Spring
semester
Input/Output
This is yet another imperative feature of C. Reading values from the user and printing
values to the terminal impart a sequential flavor to the program. If you print a variable
and then do some computation, you get some output. Instead, if you do the computations
first and then print the same variable, you may get a different output. It is very essential
that you understand the precise flow of execution of a C program. Well, so far you have
encountered only flat sequences of statements executed one-by-one from top to bottom.
Things start getting complicated as you encounter jump instructions (conditionals, loops
and function calls). For effective computation you need these jump instructions.
Imperative programming may be a complete mess, unless you understand the control
flow thoroughly.
Standard input/output
This is the direct method of communicating with the user, namely, reading from and
writing to the terminal. Here are the basic primitives for doing these.
scanf
Read from the terminal.
printf
Write to the terminal.
Scanning values
The usage procedure for scanf is as follows:
scanf(control string, &var1, &var2, ...);
The primitive scanf waits for the user to enter a value by the keyboard. After the user
writes a value and hits the enter button, the value goes to the memory location allocated
to the variable specified. So scanf is another way of assigning values to variables.
The control string specifies the data type that is to be read from the terminal. Here is a
list of the most used formats:
%d Read an integer in decimal.
%o Read an integer in octal.
%x,%X Read an integer in hexadecimal.
%i
Read an integer in decimal/octal/hex. If the integer starts
with 0x or 0X, treat it as a hexadecimal integer, else if it
starts with 0, treat it as an octal integer, otherwise treat it as
a decimal integer.
%u Read an unsigned integer in decimal.
%hd,%hi,%ho,%hu,%hx,%hX Read a short integer.
%ld,%li,%lo,%lu,%lx,%lX Read a long integer.
%Ld,%Li,%Lo,%Lu,%Lx,%LX
Read a long long integer. This is not an ANSI C feature,
but works well in Linux. Replacing L by ll (i.e., using
%lld, %lli, etc.) continues to work in Linux and may be
better ported to other architectures. Some compilers also
support %q (quad).
%f Read a float.
%e Read a float in the scientific (exponential) notation.
%lf,%le Read a double.
%Lf,%Le Read a long double.
%c Read a single character.
%s Read a string of characters.
Example
int a;
unsigned long b;
float x, y;
char c, s[64];
scanf("%d",&a); /* Read the integer a in decimal */
scanf("%x",&b); /* Read the integer b in hexadecimal */
scanf("%f",&x); /* Read the floating point number x in decimal
notation */
scanf("%e",&y); /* Read the floating point number y in the
scientific notation */
scanf(" %c",&c); /* Read the character c */
scanf(" %s",s); /* Read a string and store in s */
/* For reading strings the ampersand (&) is not
needed */
Suppose that the user enters the following values:
123 123 -123.456 1.23e-6 a Hey! I am your instructor.
Most of the readings go as expected. a receives the decimal value 123, b receives 0x123
(which is 291 in decimal), x and y respectively receive -123.456 = -1.23456e2 and
1.23e-6 = 0.00000123. Also c obtains the value 'a' (whose ASCII value is 97).
However, a problem comes with the string s: it receives the value "Hey!" only. The rest
of the input is lost! The situation is actually worse: the rest is not lost. The computer
remembers this part and supplies this to the next scanf, if any is executed. Why does it
occur? The reason is: scanf stops reading as soon as it encounters a white character
(space, tab, new line, etc.). You have to do something more complicated in order to read
strings with spaces. Note also that the scanning of c requires a space before the %c. This
is for consuming the space following the value of y given by the user. The same applies
to the reading of s. Reading characters and strings is often too painful in C. Here are the
basic rules:
• scanf stops reading as soon as it encounters a white character. The trailing white
character remains in the input stream.
• Leading white characters are ignored, when numbers are read.
• White characters are characters and so are not ignored, when characters and
strings are read.
You can read more than one variables in a single scanf. The six scanf's for the last
It is not at all clear that this algorithm works correctly. The correctness can be established
from the following invariance property:
Claim: Whenever the continuation condition for the above loop is checked, we have:
gcd(r2,r1) = gcd(a,b), (1)
u2 * a + v2 * b = r2, (2)
u1 * a + v1 * b = r1. (3)
Proof The three conditions are obviously true at the beginning of the first iteration; this
is how the values are initialized. Now suppose that the relations hold for certain iteration
with r1 > 0. The loop body is then executed. First, the quotient q and the remainder r of
Euclidean division of r2 by r1 is computed. By Euclid's gcd theorem
gcd(r1,r) = gcd(r2,r1) = gcd(a,b).
Moreover,
u = u2 - q * u1, and v = v2 - q * v1,
and so
u * a + v * b
= (u2 - q * u1) * a + (v2 - q * v1) * b
= (u2 * a + v2 * b) - q * (u1 * a + v1 * b)
= r2 - q * r1
= r.
Thus the three equations (1)-(3) continue to be satisfied for the new r,u,v values. QED
The loop terminates when r1 becomes 0. In that case gcd(a,b) = gcd(r2,r1) = gcd(r2,0) =
r2 = u2 * a + v2 * b, and, therefore, r2,u2,v2 constitute a desired set of values for the
extended gcd.
Let us look at the trace of the values stored in different variables for a sample run with
a=78 and b=21.
Iteration No r2 r1 u2 u1 v2 v1 q r u v u2*a+v2*b
Before loop 78 21 1 0 0 1 - - - - 78
1 78
21
21
15
1
0
0
1
0
1
1
-3
3
3
15
15
1
1
-3
-3
78
21
2 21
15
15
6
0
1
1
-1
1
-3
3
4
1
1
6
6
-1
-1
4
4
21
15
3 15
6
6
3
1
-1
-1
3
-3
4
4
-11
2
2
3
3
3
3
-11
-11
15
6
4 6
3
3
0
-1
3
3
-7
4
-11
-11
26
2
2
0
0
-7
-7
26
26
6
3
gcd(78,21) = 3 = (3) * 78 + (-11) * 21
Nested loops
One or more loops can be nested inside another loop. In that case the inner loops usually
have continuation conditions dependent on variables different from the variable(s)
governing the continuation of the outer loop. The programmer should be sufficiently
careful so as not to do something silly inside the inner loops, that affects the behavior of
the outer loop.
Example: [Bubble sort]
Suppose you have an array A of n elements (say, integers). They are stored in the array
locations A[0],A[1],...,A[n-1]. We want to rearrange these integers in such a way that
after the rearrangement we have
A[0] <= A[1] <= A[2] <= ... <= A[n-1].
Such an array is called a sorted array and the process of making the array sorted is
refered to as sorting. Sorting is a basic and fundamental computational problem. There
are several algorithms proposed for sorting. For the time being, let us look at an
algorithm known as bubble sort.
Animation example : bubble sort Interactive animation : bubble sort
The bubble sort algorithm can be implemented using a singly nested loop as follows:
for (i=n-2; i>=0; --i) { /* Attempt to bubble till the value of i
*/
for (j=0; j<=i; ++j) { /* Run j from 0 to the current upper
bound i */
if (A[j] > A[j+1]) { /* Two consecutive elements are in the
opposite order */
/* Swap A[j] and A[j+1] */
t = A[j]; /* Store A[j] in a temporary variable t
*/
A[j] = A[j+1]; /* Change A[j] to A[j+1] */
A[j+1] = t; /* Change A[j+1] to the old value of
A[j] stored in t */
}
}
}
Example: [Selection sort]
The working of the selection sort is somewhat similar to that of bubble sort. Here the
outer loop runs over i ranging from n-1 down to 1. For a given i, the largest element in
the subarray A[0],A[1],...,A[i] is found out and is swapped with the element A[i]. Thus
during the first iteration of the outer loop A[n-1] receives the largest element in the array,
in the second iteration A[n-2] receives the second largest element, and so on.
Animation example : selection sort Interactive animation : selection sort
The code for selection sort follows:
for (i=n-1; i>=1; --i) {
/* First find the maximum element of A[0],A[1],...,A[i] */
/* Initialize maximum entry to be the leftmost one */
maxidx = 0;
max = A[0];
/* Now search for a potentially bigger maximum */
for (j=1; j<=i; ++j) {
if (A[j] > max) { /* An element bigger that the current
maximum is located */
/* Adjust the maximum entry */
maxidx = j;
max = A[j];
}
}
/* Swap A[i] with the maximum element */
A[maxidx] = A[i]; /* Store the last element at index maxidx */
A[i] = max; /* Store the maximum at the last index */
}
Example: [Insertion sort]
Here is yet another sorting algorithm that uses nested loops. Here the outer loop runs over
a variable i ranging from 1 to n-1. For a particular i, the portion A[0],A[1],...,A[i-1] is
already sorted. Then the element A[i] is considered and is inserted at the appropriate
position in the sorted list A[0],A[1],...,A[i-1]. That will make a bigger sorted list
A[0],A[1],...,A[i]. When the loop body finishes execution with i=n, the entire array is
sorted.
Animation example : insertion sort Interactive animation : insertion sort
Here is the code for insertion sort.
for (i=1; i<n; ++i) { /* Consider A[i] */
/* Search for the correct insertion location of A[i] */
t = A[i]; /* Store A[i] in a temporary variable
*/
j = 0; /* Initialize search location */
while (t > A[j]) ++j; /* Skip smaller entries */
/* Here j holds the desired insertion location */
/* Shift forward the remaining entries each by one location */
for (k=i-1; k>=j; --k) A[k+1] = A[k];
/* Finally insert the old A[i] at the j-th location */
A[j] = t;
}
Flow control inside loops
The continuation condition dictates whether the loop body is to be repeated or skipped.
There are some constructs by which this natural flow can be altered.
The break statement
A loop may be forcibly broken from inside irrespective of whether the continuation
condition is satisfied or not. This is achieved by the break statement.
Example: Let us write the gcd algorithm with explicit break statement. Here we make
the loop an infinite one. The check whether b equals 0 is carried out inside the loop. If the
check succeeds, the loop is broken explicitly by a break statement.
while (1) {
if (b == 0) break;
r = a % b;
a = b;
b = r;
}
printf("gcd = %d\n", a);
Note that any loop can be implemented as an infinite loop with an explicit break. The do-
while loop
do {
execute loop body;
} while (continuation condition is true);
is equivalent to
do {
execute loop body;
if (continuation condition is false) break;
} while (1);
and also to
while (1) {
execute loop body;
if (continuation condition is false) break;
}
Interactive animation : for loop with break
In case of nested loops, a break statement causes the innermost loop (in which the
statement is executed) to be broken. As a toy example, suppose that we want to compute
the sum of gcds of all pairs (a,b) with 1<=a<=b<=20. Here is an implementation with
explicit break statements.
/* Initialize sum */
sum = 0;
for (i=1; i<=20; ++i) {
for (j=i; j<=20; ++j) {
/* Now we plan to compute gcd(j,i) */
/* But we must not disturb the loop variables */
/* So we copy j and i to temporary variables a and b and
change those copies */
a = j; b = i;
/* The Euclidean gcd loop */
while (1) {
if (b == 0) break; /* gcd computation is over, so break the
while loop */
r = a % b;
a = b;
b = r;
}
/* When the while loop is broken, a contains gcd(j,i). Add it
to the accumulating sum. */
sum += a;
}
}
printf("The desired sum = %d\n", sum);
Next follows a more obfuscating implementation of the same algorithm. Here all the
loops are broken with explicit break statements. Moreover, the break statements occur in
the middle of the loops. Finally, the gcd loop is rewritten so that we can break as soon as
we find a zero remainder. In that case, b holds the desired gcd.
sum = 0; /* Initialize sum to 0 */
i = 0; /* Initialize the outer loop variable */
while (1 != 0) { /* This condition is always true */
j = ++i; /* Increment i and assign the
incremented value to j */
if (j == 21) break; /* Break the outermost loop */
while (3.1416 > 0) { /* This condition is always true */
a = j; b = i; /* Copy j and i to temporary variables
*/
while ('A') { /* This condition is again always true,
since 'A' = 65 */
r = a % b; /* Compute next remainder */
if (!r) break; /* Break the innermost loop */
a = b; /* Adjust a and b and */
b = r; /* prepare for the next iteration */
} /* End of innermost loop */
sum += b; /* Add gcd(j,i) to the accumulating sum
*/
if (j == 20) break; /* Break the intermediate loop */
++j; /* Prepare for the next value of j */
} /* End of intermediate loop */
} /* End of outermost loop */
printf("The desired sum = %d\n", sum);
Well then, is it a good style to write programs this way? Certainly no! This makes your
code quite unreadable to (and hence unusable by) others. Even if some code is meant for
your personal consumption only, debugging it may cause you enough headache, in
particular, when you are already pretty tired or hungry and plan to finish the day's
programming as early as possible.
Programming is fun anyway. For the kick you may at your leisure time make attempts to
write and/or understand obfuscated codes. So then, what does the following program
print (as a function of n)?
#include <stdio.h>
main ()
{
unsigned int n, i, j, s;
printf("Enter a positive integer : ");
scanf("%d",&n);
s = 0x00000041 ^ (unsigned int)'A';
while (i = --n) while (j = --i) while (--j) ++s;
printf("s = %d\n", s);
}
The continue statement
The continue statement also affects the normal execution of a loop. It does not cause the
loop to terminate, but throws the control to the top of the loop ignoring the remaining part
of the loop body for the current iteration.
Example: Suppose we want to print the integers 1,2,...,100 neatly with 10 integers
printed in a line. Here is how this can be done:
for (i=1; i<=100; ++i) {
printf("%4d",i);
if (i%10 != 0) continue;
printf("\n");
}
Here if i is a multiple of 10, the new line character is printed. Otherwise the continue
statement lets the control flow reach the top of the loop, i.e., to the loop increment area
where the variable i is incremented. The same effect can be realized by the following
while loop:
i = 0;
while (i < 100) {
++i;
printf("%4d",i);
if (i%10 != 0) continue;
printf("\n");
}
Interactive animation : for loop with continue
Course home
CS13002 Programming and Data
Structures Spring
semester
Functions and recursion
Imagine a hotel with infinitely many rooms 0,1,2,... On a rainy night all the rooms
numbered 1,2,3,... are occupied by tenants. Room number 0 is used as the reception, it
being opposite to the entrance. Every tenant was enjoying TV and waiting for a
sumptuous dinner being prepared in the adjacent restaurant.
All of a sudden a bus carrying another infinite number of passengers arrives in front of
the hotel's entrance. The chauffeur meets the manager and requests him to give rooms to
all the passengers. It was a stormy night and there were no other hotels in the vicinity. So
the manager devises a plan. He first relocates the existing tenants, so that the tenant at
room no m goes to room no 2m-1 for all m=1,2,3,... He numbers the new guests -1,-2,-
3,... and allocates the room 2m for passenger -m. He then writes a small computer
program that notifies people of the new room allotment. He uses the following function:
0 if n = 0, f(n) = 2m - 1 if n = m > 0, 2m if n = -m for some m > 0.
Everybody seems happy at this. Only the boarder of room no 224,036,583
-1 (the largest
known prime number of today, a 7235733-digit number) raises an objection indicating
that he has to move too many rooms ahead. He insists that the current occupant of room
number n should not be asked to shift by more than n/2 rooms. The manager complies
and comes up with a second function:
g(n) =
0 if n = 0, 3m - 2 if n = 2m - 1 for some m > 0, 3m - 1 if n = 2m for some m > 0, 3m if n = -m for some m > 0.
The manager allegedly wrote a C program. His initial program looked like:
#include <stdio.h> int f ( int n ) { if (n == 0) return (0); else if (n > 0) return (2*n-1); else return (-2*n); } int main () { int n;
while (1) { printf("Input n : "); scanf("%d",&n); printf("Room number for %d is %d.\n", n, f(n)); } }
After the request of Mr. Mersenne the XLI, the manager changed his program to:
#include <stdio.h> int g ( int n ) { int m; if (n == 0) return (0); else if (n < 0) return (-3*n); else { m = (n + 1)/2; if (n % 2 == 0) return (3*m-1); else return(3*m-2); } } int main () { int n; while (1) { printf("Input n : "); scanf("%d",&n); printf("Room number for %d is %d.\n", n, g(n)); } }
There is a technical problem here. Though the hotel in the story has infinitely many
rooms and the bus carried infinitely many passengers, C's integers are limited in size (32
or 64 bits). But then since this is a story, we may take liberty to imagine about a fabulous
C compiler that supports integers of any size!
Translating mathematical functions in C
The above example illustrates how we can write functions in C. A function is expected to
carry out a specific job depending on the argument values passed to it. After the job is
accomplished, the function returns some value to the caller. In the above example, the
function f (or g) accepts as an argument the number of the tenant, computes the room
number of the tenant and returns this value to the place where it is called.
The basic syntax of writing a function goes like this:
return_type function_name ( list_of_arguments ) { function body
}
The argument list should be a comma-separated list of type-name pairs, where type is any
valid data type and name is any legal formal name of a variable. Argument values can be
accessed inside the function body using these names.
The return type of a function should again be a valid data type (like int, float, char *).
A function may even choose to return no explicit values in which case its return type is to
be mentioned as void.
The function body starts with a declaration of local variables. These variables together
with the function arguments are accessible only in the function body and not outside it.
After the declarations one writes the C statements that compute the specific task that the
function is meant for. The function returns a value using the statement:
return (return_value);
The parentheses around return_value are optional. In case a function is expected to return
nothing (i.e., void), the return statement looks like:
return;
The return statement not only returns a value (possibly void) to the caller, but also
returns the control back to the place where it is called. In case no explicit return
statements are present in the function body, control goes back to the caller after the entire
body of the function is executed.
Calling a function uses the following syntax:
function_name ( argument_values )
Here argument values are provided as a comma-separated list of expressions. The formal
names of the function arguments have absolutely nothing to do with the expressions
passed during the call. However, the number of arguments and their respective types in a
function call must match with those that are declared in the function header. In some
cases data of different types are implicitly typecast when passed to functions, but it is
advisable that you do not rely too much on C's automatic typecasting mechanism. That
may lead to unwelcome run-time errors.
Functions may call other functions in the function body. In fact, a function call can be
treated as an expression. It is like referring to a+b as add(a,b). Just because your
keyboard does not support enough symbols, you have to call your functions by special
names.
A function is regarded as an isolated entity that can perform a specific job. Therefore, if
that specific job is to be carried out several times (possibly) with different argument
values, functions prove to be useful. Functions also add to the legibility and modularity of
programs, thereby enhancing simpler debugging. It is a bad practice to write long
monolithic programs. We encourage you to break up the monolithic structure into
logically coherent parts and implement each part as a function.
Functions (like loops) provide a way in which the standard sequential top-to-bottom flow
of control is disturbed. This is the reason why functions may pose some difficulty to an
inexperienced programmer. But the benefits they provide far outweigh one's efforts to
master them. Guess what, you too must master them!
Example: If a program requires computation of several gcd's, it is advisable to write a
function and call it with appropriate parameters as and when a gcd is to be calculated.
#include <stdio.h> int gcd ( int a , int b ) { int r; /* Check for errors : gcd(0,0) is undefined */ if ((a==0) && (b==0)) return (0); /* Make the arguments non-negative */ if (a < 0) a = -a; if (b < 0) b = -b; /* Special case : gcd(a,0) = a */ if (b == 0) return (a); /* The Euclidean gcd loop */ while (1) { r = a % b; if (r == 0) return (b); a = b; b = r; } } int main () { int i, j, s; s = 0; for (i=1; i<=20; ++i) { for (j=i; j<=20; ++j) { s += gcd(j,i); } } printf("The desired sum = %d\n", s); }
Example: In all the previous examples we have made the function call at only one place.
One may replace this call by an explicit code carried out in the function. However, if the
same function is called multiple times, inserting an equivalent code at all call locations
increases the size of the code and calls for separate maintenance of the different copies.
This is your first tangible benefit of using functions.
Think of a situation when a committee of n members need be formed. The committee
must have a core team consisting of at least two members and no more than one-third of
the entire committee. In how many ways the core committee may be selected?
Here is a program that computes this number: function1.c
#include <stdio.h> int factorial ( int n ) { int prod = 1, i; for (i=2; i<=n; ++i) prod *= i; return(prod); } int binomial ( int n , int r ) { return(factorial(n)/(factorial(r)*factorial(n-r))); } int main () { int n, i, s = 0; printf("Total number of members : "); scanf("%d",&n); for (i=2; i<=n/3; ++i) s += binomial(n,i); printf("Total number of ways = %d\n", s); }
Example: Here is a more complicated example. Suppose we want to print the square root
of an integer truncated after the third digit following the decimal point. We use the
standard algorithm taught in the school. The algorithm finds successive digits in the
square root. The following example illustrates a typical computation of a square root:
Here is the complete source code: function2.c #include <stdio.h> int nextDigit ( int r , int s , int grp ) /* Here r is whatever remains, s is the sqrt found so far, and grp is the next two digits to be considered. */ { int d = 0; /* Keep on searching for the next digit in the square root */ while ((20*s+d)*d <= 100*r+grp) ++d; /* Here d is just one bigger than the correct digit */ return(d-1); } void printSqrt ( int n ) { int s, /* Square root found so far */ r, /* Whatever remains */ d, /* next digit */ nl, /* Number of digits to the left of the decimal point */ nr = 3, /* Number of digits to the right of the decimal point */ grp[8], /* 2-digit groups */ sgn, /* Sign of n */ i; /* An index */ if (n < 0) { sgn = 1; n = -n; } else sgn = 0; if (n == 0) { nl = 1; grp[0] = 0; } else { nl = 0; while (n != 0) { grp[nl] = n % 100; /* Save next 2-digit group */ n /= 100; ++nl; } } /* Initialize */ s = 0; r = 0; /* First print the digits to the left of the decimal point */ for (i=nl-1; i>=0; --i) { d = nextDigit(r,s,grp[i]); printf("%d",d); r = (100 * r + grp[i]) - (20 * s + d) * d; s = 10 * s + d; } /* Print the decimal point */ printf(".");
/* Print digits after the decimal point */ for (i=0; i<nr; ++i) { d = nextDigit(r,s,0); printf("%d",d); r = 100 * r - (20 * s + d) * d; s = 10 * s + d; } /* Square root of negative numbers should be imaginary */ if (sgn) printf("i"); printf("\n"); } int main () { int n; printf("Enter an integer : "); scanf("%d",&n); printSqrt(n); }
Example: C provides many built-in functions. For example, the main function is a built-
in function and must be present in any executable program. It returns an int value. It
may also accept arguments. Here is the complete prototype of main:
int main ( int argc , char *argv[] ) { ... }
One cannot call the main function from any other function in a program. If that is the
case, who calls it and who uses its return value? The external world! When you run your
program from a shell (possibly by typing ./a.out), you can pass (command-line)
arguments to main. Moreover, when the program terminates, the return value of main is
returned to the shell. You may choose to use the value for doing something useful.
Think of a call like this:
./a.out -5 3.1416 foo.txt
When the main function starts execution, its argc parameter receives the value 4, because
the total number of arguments including ./a.out is 4. The other argument argv is
actually an array of arrays of characters. argv[0] gets the string "./a.out", argv[1] the
string "-5", argv[2] the string "3.1416", and argv[3] the string "foo.txt". You can
process these values from inside the main function. For example, you may supply a file
name, some initial values, etc. via command-line arguments.
Some other built-in C functions include printf and scanf. A queer thing about these
functions is that they support variable number of parameters. You can also write
functions with this property, but we won't discuss it here.
Function prototypes
As long as a function is defined earlier (in the program) than it is called, there seems to
be no problem. However, if the C compiler meets a function call before seeing its
definition, it assumes that the function returns an int. Eventually the compiler must
encounter the actual definition of the function. If the compiler then discovers that the
function returns a value of type other than int, it issues a mild warning message.
Compilation then proceeds successfully. However, when you run this program, you may
find awkward run-time errors. That happens because the run-time system typecasts data
of another type to int. That may create troubles in esoteric situations.
The way out is to always define a function earlier than it is called. Unfortunately, there is
a situation where this cannot be done, namely, when a function egg() calls a function
chicken() and the function chicken() also calls egg(). Which function will then be
defined earlier?
The most graceful way to tackle this problem is to define the prototype of a function
towards the beginning of your program. The prototype only mentions the return type and
parameter types. The body may be (and must be) defined somewhere else, even after it is
called. A function prototype looks like the first line of the function followed by a
semicolon (instead of its body surrounded by curly braces).
return_type function_name ( argument_list );
For example, the gcd, nextDigit and printSqrt functions defined above have the following
prototypes:
int gcd ( int a , int b ); int nextDigit ( int r , int s , int grp ); void printSqrt ( int n );
During a prototype declaration the names of the variables play no roles. It is the body that
is expected to make use of them, and the prototype has no body at all. So these names
may be blissfully omitted. That is, it is legal to write:
int gcd ( int , int ); int nextDigit ( int , int , int ); void printSqrt ( int );
When you actually define the function, its header must faithfully match with the
prototype found earlier.
Archiving C functions
Function prototypes are also useful during packaging of C functions in libraries. We
explain the concept with an example. Assume that you are writing a set of useful tools to
be used by foobarnautic scientists and engineers. The subject deals with two topics:
foomatics and bargodics. You plan to write your foomatic functions in two files foo1.c
and foo2.c, the first containing the basic tools and the second some advanced tools. For
bargodics too you plan to write two C sources bar1.c and bar2.c. Later you realize that
some bargodic topics are so advanced that they may better be called esoteric and should
be placed in a third file bar3.c. All these five files have C functions, each meant for
doing some specific job, like computing fooctorials, barnomial coefficients etc. However,
none of these files should have a main function. A future user of your library will write
the main function in her program, call your foobarnautic functions from her program and
finally compile and run her program to unveil foobarnautic mysteries.
You first write the would-be useful functions in five files as mentioned above. You then
compile each such file to an object file (not an executable file, since no file has a main).
cc -c -o foo1.o foo1.c cc -c -o foo2.o foo2.c cc -c -o bar1.o bar1.c cc -c -o bar2.o bar2.c cc -c -o bar3.o bar3.c
Five object files foo1.o, foo2.o, bar1.o, bar2.o and bar3.o are obtained after
successful compilation. You then join these object files to an archive (library):
ar rc libfoobar.a foo1.o foo2.o bar1.o bar2.o bar3.o
The archive command (ar) creates the library libfoobar.a. You may optionally run the
following utility on this archive in order to add some book-keeping information in the
archive:
ranlib libfoobar.a
Now your library is ready. Copy it to a system directory if you have write permission
there, else store it somewhere else, say, in the directory /tmp/foobar/lib.
Now when the future user plans to use your library, she simply compiles her program
fooexplore.c (with main) as:
cc fooexplore.c -lfoobar
if the library libfoobar.a resides in a system directory. If not, she should specifically
mention the directory of the library and compile her program as:
cc fooexplore.c -L/tmp/foobar/lib -lfoobar
But... Something goes wrong, may be terribly wrong. Her compilation attempt issues a
hell lot of warning messages. In fact, cc may even refuse to compile fooexplore.c. That
was your fault, not the user's. You have missed to do some vital things. As soon as the
frustrated programmer rings you up, you realize your fault. Now do the remaining things.
Create header files foo1.h, foo2.h, bar1.h, bar2.h and bar3.h. These files should
contain only the following:
• All new type definitions that you used in your library.
• All global variables and constants you used in your library.
• Prototypes of all functions defined in the library.
For example, foo1.h may look like: /************************************************************************** * foo1.h : Header file for basic foomatic utilities * * Created by : 04FB1331 Foolan Barik * * Last updated : January 08, 2005 * * Copyright 2005 by the Dept of Foobarnautic Engg, IIT Kharagpur, India * **************************************************************************/ /* Prevent accidental multiple #inclusion of this header file */ #ifndef _FOO1_H #define _FOO1_H /* New type definitions */ typedef unsigned long int fooint; typedef long double fooreal; typedef unsigned char foochar; ... /* Macros */ #define _FOO_BAR_TRUE 1 #define _FOO_BAR_FALSE 0 #define _FOO_BAR_PI 3.141592653589793238462643383 ... /* Global constants */ static const fooint foorams[8] = { 0xf00ba000, 0xf002ba00, 0xf0046ba0, 0xf008acba, 0xba0f0000, 0xba01f000, 0xba035f00, 0xba079bf0 }; ... /* Function prototypes. */ /* These functions are external to the user's programs. */
/* So use the extern keyword. */ extern fooint fooctorial ( fooint ) ; extern fooreal fooquation ( fooreal , fooint * , foochar ) ; ... #endif
If the source foo1.c contains these definitions, remove them from the C file and instead
#include "foo1.h" towards the beginning of foo1.c. Do not define any function in a
header file.
Do the above for all sources. Recompile your library, copy the new libfoobar.a once
again to an appropriate directory.
Then choose a suitable directory for putting the headers. If you have permission to write
in the system's include directory (usually /usr/include), create a directory foobar
under this directory and copy your five header files to this new directory. If you do not
have permission to write in /usr/include, create the directory /tmp/foobar/include
and copy the header files there. Call back the user and notify her of these new
developments.
The user then adds the following lines to her source code fooexplore.c.
Include the header <ctype.h> in order to access several character-related functions. You
don't have to link any special library during compilation time. Many of these functions
return Boolean values (true and false). However, C does not have a default Boolean data
type. Here the Boolean value is returned as an integer (int) with the convention that 0
means "false" and any non-zero value means "true".
int isalpha (int c);
Returns true if and only if c is an alphabetic character ('A'-'Z' and 'a'-'z'). int isupper (int c);
Returns true if and only if c is an upper-case alphabetic character ('A'-'Z'); int islower (int c);
Returns true if and only if c is a lower-case alphabetic character ('a'-'z'); int isdigit (int c);
Returns true if and only if c is a decimal digit ('0'-'9'); int isxdigit (int c);
Returns true if and only if c is a hexadecimal digit ('0'-'9', 'A'-'F' and
'a'-'f'). int isalnum (int c);
Returns true if and only if c is an alphanumeric character ('A'-'Z', 'a'-'z'
and '0'-'9'). int isspace (int c);
Returns true if and only if c is a white space character (space, tab, new-line, form-
feed, carriage-return). int isprint (int c);
Returns true if and only if c is a printable character (0x20-0x7e). int ispunct (int c);
Returns true if and only if c is a printable character other than space, letter and
digit. int isgraph (int c);
Returns true if and only if c is a graphical character (0x21-0x7e).
int iscntrl (int c);
Returns true if and only if c is a control character (0x00-0x1f and 0x7f). int tolower (int c);
If c is an upper-case letter, the corresponding lower-case letter is returned.
Otherwise, c itself is returned. int toupper (int c);
If c is a lower-case letter, the corresponding upper-case letter is returned.
Otherwise, c itself is returned.
The stdlib library
The standard library may be included by including the header <stdlib.h>. No separate
libraries need be linked during compilation time.
int atoi (const char *s);
Returns the integer corresponding to the string s. For example, the string "243"
corresponds to the integer 243. long atol (const char *s);
Returns the long integer corresponding to the string s. For example, the string
"243576809" corresponds to the integer 243576809L. double atof (const char *s);
Returns the floating point number corresponding to the string s. For example, the
string "243576.809" corresponds to the floating-point number 2.43576809e05
(in the scientific notation). int rand ();
Returns a random integer between 0 and RAND_MAX. In our lab RAND_MAX is 231-1.
void srand (unsigned int s);
Seed the random number generator by the integer s. A natural seed is the current
system time. The following statement does this. srand((unsigned int)time(NULL));
In order to use the time function, you should #include <time.h>.
int abs (int n);
Returns the absolute value |n|of the integer n. long labs (long n);
Returns the absolute value |n|of the long integer n. int system (const char *s);
Passes the string argument s to be executed by the shell. For example,
system("clear"); clears the screen.
Passing parameters
In C all parameters are passed by value. This means that for a call like
u = fooquation(x+y*z,&n,c);
the arguments are first evaluated and subsequently the values are copied to the formal
parameters defined in the function header:
long double fooquation ( long double x , unsigned long *p , unsigned char c ) { long double w; ... return(w); }
During the call, the formal parameter x gets the value of the expression x+y*z, the
pointer p gets the address of n and the formal parameter c obtains the value stored in the
variable c during the time of the call. The formal arguments x,p,c are treated in
fooquation as local variables. Any change in these values is not reflected outside the
function. Thus the variables x,y,z,c in the caller function are unaffected by whatever
fooquation does with the formal arguments x,p,c. The caller variable n is an exception.
We didn't pass n straightaway to fooquation, we instead passed its address. So
fooquation receives a copy of this address in its formal argument p. If the function
modifies the pointer p, this does not change the address of n in the caller. However,
fooquation may wish to write to the address passed to p. This modifies n, but not &n.
If you want to modify the value of some variable in a function, pass to the function a
pointer to the variable.
Here is a failed attempt to swap the values of two variables:
void badswap ( int a , int b ) { int t; t = a; a = b; b = t; } int main () { int m = 51, n = 23; printf("m = %d, n = %d.\n", m, n); badswap(m,n); printf("m = %d, n = %d.\n", m, n); }
The program prints:
m = 51, n = 23. m = 51, n = 23.
The call of badswap produces no effect on m and n. In order to produce the desired effect,
use the following strategy:
void swap ( int *ap , int *bp ) { int t; t = *ap; *ap = *bp; *bp = t; } int main () { int m = 51, n = 23; printf("m = %d, n = %d.\n", m, n); swap(&m,&n); printf("m = %d, n = %d.\n", m, n); }
This time the program prints:
m = 51, n = 23. m = 23, n = 51.
Animation example : parameter passing in C
Now what should you do if you want to change a pointer? The answer is simple: pass a
pointer to the pointer. How? We will give an answer to this new question later. Hold your
patience.
Recursive functions
Recall that certain functions are defined recursively, i.e., in terms of itself. For example,
consider the function F that maps n to the n-th Fibonacci number, i.e., F(n) = Fn. (In
fact, every sequence is a function in a natural way.) We then have:
0 if n = 0, F(n) = 1 if n = 1, F(n-1) + F(n-2) if n >= 2.
It is then tempting to write F as follows:
int F ( int n ) { if (n == 0) return (0); if (n == 1) return (1); return (F(n-1)+F(n-2)); }
Does it work? The potential problem is: if F calls F itself with different parameter values,
what would be the formal argument n for F. Every new invocation of F is expected to
erase the old value of n. That would lead to error. In the above example, when F(n-1)
returns, we have to make a second invocation F(n-2). Now if by this time the value of n
has changed, we expect to get incorrect results. So what is the way out?
The answer is: there is no way out. In fact, there has not been any problem at all. The
above function perfectly works.
Older languages like FORTRAN (designed in the 50's) used to face a problem, and there
was again no way out. You cannot call a function from itself. That is, recursion was
strictly prohibited.
C A R Hoare first proposed a way to work around with this problem. He introduced the
concept of nests which we nowadays refer to as stacks. Every time a function is called, its
formal parameters and local variables are pushed to the top of the call stack. In this way
different invocations refer to different memory locations for accessing variables of the
same name. When a function returns, its local data are popped out of the stack and
control returns to the caller function for which variables reside in the current top of the
stack.
The first high-level language that supported recursion was ALGOL. Most languages
designed after that (late sixties onward) support recursion. C is no exception. In fact, the
latest version of FORTRAN (FORTRAN 90) also supports recursion.
Animation example : recursive computation of Fibonacci numbers
Interactive animation : recursive computation of Fibonacci numbers
Here is another example: recursive computation of the factorial function.
int factorial ( int n ) { if (n < 0) return (-1); /* Error condition */ if (n == 0) return (1); /* Basis case */ return(n * factorial(n-1)); /* Recursive call */ }
Animation example : recursive computation of the factorial function
Interactive animation : recursive computation of the factorial function
Example: [Merge sort]
This is a very interesting recursive sorting technique. The array to be sorted is first
divided in two halves of nearly equal sizes. Each half is then recursively sorted. Two
sorted subarrays are then merged to form the final sorted list. Recursion stops when the
array is of size 1. Such an array is already sorted.
The correctness of this algorithm can be established by the principle of strong
mathematical induction. The base case (arrays of size 1) is obvious. For the inductive
step, it suffices to prove that the merging routine correctly merges two sorted arrays. We
leave out the details here.
Animation example : merge sort Interactive animation : merge sort
Here is a recursive implementation of the merge sort algorithm. For simplicity, we work
with a global array, so that we do not have to bother about passing the array as a function
argument.
#include <stdio.h> #include <stdlib.h> #include <time.h> #define MAXSIZE 1000 int A[MAXSIZE]; /* Function prototypes */ void mergeSort ( int, int ); void merge ( int, int, int ); void printArray ( int ); void mergeSort ( int i , int j ) /* i and j are the leftmost and rightmost indices of the current part of the array being sorted. */ { int mid; if (i == j) return; /* Base case: an array of size 1 is sorted */ mid = (i + j) / 2; /* Compute the mid index */ mergeSort(i,mid); /* Recursively sort the left half */ mergeSort(mid+1,j); /* Recursively sort the right half */ merge(i,mid,j); /* Merge the two sorted subarrays */ } void merge ( int i1, int j1, int j2 ) { int i2, k1, k2, k; int tmpArray[MAXSIZE]; i2 = j1 + 1; k1 = i1; k2 = i2; k = 0; while ((k1 <= j1) || (k2 <= j2)) { if (k1 > j1) { /* Left half is exhausted */ /* Copy from the right half */ tmpArray[k] = A[k2];
++k2; } else if (k2 > j2) { /* Right half is exhausted */ /* Copy from the left half */ tmpArray[k] = A[k1]; ++k1; } else if (A[k1] < A[k2]) { /* Left pointer points to a smaller value */ /* Copy from the left half */ tmpArray[k] = A[k1]; ++k1; } else { /* Right pointer points to a smaller value */ /* Copy from the right half */ tmpArray[k] = A[k2]; ++k2; } ++k; /* Advance pointer for writing */ } /* Copy temporary array back to the original array */ --k; while (k >= 0) { A[i1+k] = tmpArray[k]; --k; } } void printArray ( int s ) { int i; for (i=0; i<s; ++i) printf("%3d",A[i]); printf("\n"); } int main () { int s, i; srand((unsigned int)time(NULL)); printf("Array size : "); scanf("%d",&s); for (i=0; i<s; ++i) A[i] = 1 + rand() % 99; printf("Array before sorting : "); printArray(s); mergeSort(0,s-1); printf("Array after sorting : "); printArray(s); }
Another recursive sorting technique is called the quick sort. For random arrays quick sort
turns out to be one of the practically fastest sorting algorithms. Invented by C A R Hoare,
this algorithm demonstrates the necessity for the facility of recursion in a high-level
language. Inspired by this (and other) needs, Hoare himself wrote a commercial compiler
for the language ALGOL 60.
Here we describe a simple version of the quick sort algorithm that employs auxiliary
storage for partitioning the array. The idea is to choose a pivot, typically the first element
of the array. All the remaining elements are partitioned into two collections, the first
containing those array elements that are less than the pivot and the second containing the
elements not less than the pivot. Then the original array is replaced by the smaller part
followed by the pivot followed by the larger part. With this partitioning the pivot is now
in the correct position. The smaller and larger parts are then recursively sorted by the
quick sort algorithm. Once again the correctness of this algorithm can be rigorously
established using the principle of strong mathematical induction.
Animation example : quick sort with extra storage
The complete implementation of the quick sort algorithm can be found here.
#include <stdio.h> #include <stdlib.h> #include <time.h> #define MAXSIZE 1000 int A[MAXSIZE]; void quickSort ( int i , int j ) { int pivot; int leftArray[MAXSIZE], rightArray[MAXSIZE]; int lsize, rsize; int k, idx; if (i == j) return; pivot = A[i]; k = i; lsize = rsize = 0; /* Separate out the left and right parts */ while (k < j) { ++k; if (A[k] < pivot) leftArray[lsize++] = A[k]; else rightArray[rsize++] = A[k]; } /* Copy back the left part, the pivot and the right part to the original array */ k = i;
for (idx=0; idx<lsize; ++idx) A[k++] = leftArray[idx]; A[k++] = pivot; for (idx=0; idx<rsize; ++idx) A[k++] = rightArray[idx]; if (lsize > 0) quickSort(i,i+lsize-1); /* Recursive call on the left part */ if (rsize > 0) quickSort(j-rsize+1,j); /* Recursive call on the right part */ } void printArray ( int s ) { int i; for (i=0; i<s; ++i) printf("%3d",A[i]); printf("\n"); } int main () { int s, i; srand((unsigned int)time(NULL)); printf("Array size : "); scanf("%d",&s); for (i=0; i<s; ++i) A[i] = 1 + rand() % 99; printf("Array before sorting : "); printArray(s); quickSort(0,s-1); printf("Array after sorting : "); printArray(s); }
The partitioning of the array can be done in-place, i.e., without using extra storage. We
won't go to the details here. The following animation implements quick sort with in-place
partitioning.
Animation example : in-place quick sort
Recursion or iteration?
The divide-and-conquer algorithms like merge sort and quick sort give rise to a new
genre of algorithm design and analysis techniques. Until recursion could be realized,
implementing these algorithms was really non-trivial.
However, recursion is not really an unadulterated boon. To exemplify this issue, let us
compare the performances of the iterative version of the Fibonacci number generation
function and of the recursive version described above. Computation of Fn by the iterative
version (using simple loops) requires n-1 additions and some additional overheads
proportional to n.
But what about the recursive version? Let Sn denote the number of additions performed
by the iterative method for the computation of Fn. We evidently have:
Sn = 0 if n = 0 or 1, Sn-1 + Sn-2 + 1 if n >= 2.
Define the sequence
Tn = Sn + 1 for all n = 0,1,2,...
It follows that
Tn = 1 if n = 0 or 1, Tn-1 + Tn-2 if n >= 2.
Thus T0 = F1 and T1 = F2. By induction we then have Tn = Fn+1, i.e.,
Sn = Fn+1 - 1.
If n = 25, we have Sn = 121392, whereas for n = 50, we have Sn = 20365011073.
Compare these figures with the very small numbers (respectively 24 and 49) of additions
performed by the iterative method. The reason for this poor performance of the recursive
algorithm is that many Fi are computed multiple times. For example, Fn computes both
Fn-1 and Fn-2, whereas Fn-1 also computes Fn-2. It is absolutely unnecessary to recompute
the same value again and again. But unless we do something, we cannot eliminate this
massive amount of multiple computations.
It is, therefore, often advisable to replace recursion by iteration. If some function makes
only one recursive call and does nothing after the recursive call returns (except perhaps
forwarding the value returned by the recursive call), then one calls this recursion a tail
recursion. Tail recursions are easy to replace by loops: since no additional tasks are left
after the call, no book-keeping need be performed, i.e., there is no harm if we simply
replace the local variables and function arguments by the new values pertaining to the
recursive call. This leads to an iterative version with the loop continuation condition
dictated by the function arguments.
The factorial and Fibonacci routines that we presented earlier are not tail-recursive. The
factorial routine performs a multiplication after the recursive call returns, and so it feels
the necessity to store the formal parameter n. With the following implementation this
need is eliminated. Here we pass to the recursive function an accumulating product.
int facrec ( int n , int prod ) { if (n < 0) return (-1); if (n == 0) return (prod); return (facrec(n-1,n*prod)); } int factorial ( int n )
{ return (facrec(n,1)); }
The straightforward iterative version of this is the following:
int faciter ( int n ) { int prod; if (n < 0) return (-1); prod = 1; /* Corresponds to facrec(n,1) */ while (n > 0) { /* Corresponds to the sequence of recursive calls */ prod *= n; /* Second argument in the recursive call */ n = n - 1; /* Change the formal parameter */ } return (prod); }
For the Fibonacci number generator the following strategy reduces the overhead of
recursion to something proportional to n. This function returns both Fn and Fn-1. But since
a function cannot straightaway return two values simultaneously, the returning of Fn-1 is
effected by pointers. Since the computation of Fn requires only two previous values, the
efficient (linear) behavior is restored by the following recursive implementation.
int F ( int n , int *Fprev ) { int Fn_1, Fn_2; if (n == 0) { *Fprev = 1; return (0); } if (n == 1) { *Fprev = 0; return (1); } Fn_1 = F(n-1,&Fn_2); *Fprev = Fn_1; return (Fn_1+Fn_2); }
This function is not tail-recursive, but that does not matter much. In the base case, it
computes (F0,F1). From these values it computes (F1,F2), and from these the values (F2,F3),
and so on. Eventually, we get (Fn-1,Fn) which contains the desired number Fn.
Interactive animation : fast recursive computation of Fibonacci numbers
In general, it is not an easy matter to replace recursion by iteration (or more ambitiously
by tail-recursion). Whenever the replacement idea is intuitive and straightforward, one
may go for it. After all, recursion has some overheads. In most cases, however, we have
to look more deeply into the structure of the problem in order to devise a suitable iterative
substitute. Memoization and dynamic programming techniques often help. But these
topics are too advanced to be dealt with in this introductory course.
Now here is again an obfuscated code for you. Determine what the following function
computes. Express the return value as a function of the input integer n. Assume that
n >= 0.
int foo ( int n ) { int s = 0; while (n--) s += 1 + foo(n); return s; }
Course home
CS13002 Programming and Data
Structures Spring
semester
Arrays
We have already discussed how one can define and initialize arrays and access individual
cells of an array. In this chapter we introduce some advanced techniques related to
handling of arrays.
Passing arrays to functions
We have seen how individual values (variables and pointers) can be passed to functions.
Now let us see how we can pass an entire array to a function.
Suppose an array is defined as:
#define MAXSIZE 100 int myarr[MAXSIZE];
In order to pass the array myarr to a function foonction one may define the function as:
int foonction ( int A[MAXSIZE] , int size ) { ... }
This function takes two arguments, the first is an array of size MAXSIZE, and the second
an integer argument named size. Here this second argument is meant for passing the
actual size of the array. Your array can hold 100 integers. However, at a certain point of
time you may be using only 32 locations (0 through 31) of the array. The other unused
locations also hold some values. If they are not initialized, they contain unpredictable
values. You do not want these garbage values to be interpreted by your function as
important ones. So you specify the actual size to be 32. The function call should go like
this:
foonction(myarr,32);
Inside the function the array location myarr[i] can be accessed (read or written) as A[i].
It is very important to note that:
When you pass an array to a function, all changes you make in the array locations
are visible from outside.
In this example setting A[5] to 20 inside the function also changes myarr[5] to 20. This
apparently contradicts the pass-by-value call mechanism of C. But the actual scenario is
not so. When you pass an array, the entire array is not copied element-by-element. What
is copied is the address of the first (I mean, the zeroth) location of the array. That is
indeed a pointer. This dual meaning of an array will be dealt with at length later in this
chapter.
You don't have to specify the length of the array in the function declaration. This is again
because the array is not copied element-wise. Only the starting address of the array is
passed. The function call does not allocate memory for the elements of the array.
Therefore, it does not matter how big the array is. However, it is necessary to mention
that the argument that is passed is an array and not an element of the constituent data
type. The following declaration is adequate and admissible:
int foonction ( int A[] , int size ) { ... }
Animation example : passing
arrays to functions
Interactive animation : passing
arrays to functions
Strings
In C a string is defined to be a null-terminated character array. The null character ('\0')
is used to indicate the end of the string. Like any other arrays, C does not impose range
checking of array indices for strings. Declaration of an array allocates a fixed space for it.
You need not use the entire space. Instead you can store your data in the initial portion of
the array. It is, therefore, necessary to put a boundary of the actual data. This is the
reason why we passed the size parameter to foonction above. Strings handle it
differently, namely by putting an explicit marker at the end of the actual data. Here is an
example of a string:
I I T K h a r a g p u r , 7 2 1 3 0 2 \
0
0 1 2 3 4 5 6 7 8 9
1
0
1
1
1
2
1
3
1
4
1
5
1
6
1
7
1
8
1
9
2
0
2
1
2
2
2
3
2
4
2
5
2
6
2
7
2
8
2
9
Here we use an array of size 30. The string "IIT Kharagpur, 721302" is stored in the
first 21 locations. This is followed by the null character. A total of 22 characters is
needed to represent this string of length 21. Whatever follows after this null character is
irrelevant for defining the string. If we set the element at location 6 to '\0', the array
looks like:
I I T K h \ r a g p u r , 7 2 1 3 0 2 \
0 0
0 1 2 3 4 5 6 7 8 9
1
0
1
1
1
2
1
3
1
4
1
5
1
6
1
7
1
8
1
9
2
0
2
1
2
2
2
3
2
4
2
5
2
6
2
7
2
8
2
9
Considered as a string this stands for "IIT Kh".
Recall that C allows you to read from and write to the locations at indices 30,31,... of
this array. These are memory locations not allocated to the array, since its size is 30.
Writing beyond the allocated space is expected to corrupt memory or even raise fatal run-
time errors (Segmentation faults). In particular, if you do not put the null character at the
end of the string, C keeps on searching for it and may go out of the legal boundary and
create troubles.
C offers some built-in functions for working with strings. They assume (null-terminated)
strings as input and create (null-terminated) strings. You do not have to append the null
character explicitly. For example, the statement
strcpy(A,"IIT Kharagpur");
copies the string "IIT Kharagpur" to the character array A and also appends the required
null character at the end of it.
In order to use these string functions you should #include <string.h>. No additional
libraries need be linked during compilation time. The math library was quite different.
Well, mathematics and mathematicians are traditionally known to be different from the
rest of the lot!
int strlen (const char s[]);
Returns the length (the number of characters before the first null character) of the
string s. int strcmp (const char s[], const char t[]);
Compares strings s and t. Returns 0 if the two strings are identical, a negative
value if s is lexicographically smaller than t (i.e., if s comes before t in the
standard dictionary order), and a positive value if s is lexicographically larger
than t. int strncmp (const char s[], const char t[], size_t n);
Compares the prefixes of strings s and t of length n. Returns 0, a negative value
or a positive value according as whether the prefix of s is equal to,
lexicographically smaller than or lexicographically larger than the prefix of t. If a
string (s or t) is already of length l < n, then the first l characters of the string
(i.e., the entire string) is considered for the comparison. The decision of strncmp
is based on the relative placement of the prefixes according to dictionary rules.
For example, the string "IIT" comes before "MIT", "UIUC" and "IITian", but
comes after "IIIT", "BITS" and "I" in the standard dictionary order. Note that
string comparison is done in a case-sensitive manner. 'A' has the ASCII value
(65) less than that for 'a' (95) and so 'A' comes before 'a' in the lexicographic
order. It is more correct to say that comparison is done with respect to ASCII
values, whereas ASCII values are assigned to characters based broadly on the
dictionary order. Case-sensitivity is inherent in ASCII. You have to live with it. char *strcpy (char s[], const char t[]);
Copies the string t to the string s. char *strncpy (char s[], const char t[] , size_t n);
Copies the prefix of t of size n to s. Again if t is of size l < n, then only l
characters are copied to s. In all these cases a trailing null character is also copied
to s. char *strcat (char s[], const char t[]);
Appends the string t and then the null character at the end of s. The string s (a
pointer, see below) is returned. char *strncat (char s[], const char t[], size_t n);
Appends the first n characters of t and then the null character at the end of s. If t
is of length l < n, then only l characters of t are appended to s. The string s is
returned. int *strchr (const char s[], int c);
Returns the pointer to the first occurrence of the integer c (treated as a character
under the ASCII encoding). If the character c does not occur in s, the NULL
pointer is returned. int *strrchr (const char s[], int c);
Returns the pointer to the last occurrence of the integer c (treated as a character
under the ASCII encoding). If the character c does not occur in s, the NULL
pointer is returned.
Arrays and pointers
The double entendre of arrays constitutes a confusing and yet beautiful feature of C. An
array is an array, if it is viewed so. One can access elements by the usual square bracket
notation (like A[i]). In addition, an array A is also a pointer. You can assign pointers of
similar types to A and do pointer arithmetic in order to navigate through the elements of
the array.
Consider an array of integers and an int pointer:
#define MAXSIZE 10 int A[MAXSIZE], *p;
The following are legal assignments for the pointer p:
p = A; /* Let p point to the 0-th location of the array A */ p = &A[0]; /* Let p point to the 0-th location of the array A */ p = &A[1]; /* Let p point to the 1-st location of the array A */ p = &A[i]; /* Let p point to the i-th location of the array A */
Whenever p is assigned the value &A[i], the value *p refers to the array element A[i],
and so also does p[0].
Pointers can be incremented and decremented by integral values. After the assignment p
= &A[i]; the increment p++ (or ++p) lets p one element down the array, whereas the
decrement p-- (or --p) lets p move by one element up the array. (Here "up" means one
index less, and "down" means one index more.) Similarly, incrementing or decrementing
p by an integer value n lets p move forward or backward in the array by n locations.
Consider the following sequence of pointer arithmetic:
p = A; /* Let p point to the 0-th location of the array A */ p++; /* Now p points to the 1-st location of A */ p = p + 6; /* Now p points to the 7-th location of A */ p += 2; /* Now p points to the 9-th location of A */ --p; /* Now p points to the 8-th location of A */ p -= 5; /* Now p points to the 3-rd location of A */ p -= 5; /* Now p points to the (-2)-nd location of A */
Oops! What is a negative location in an array? Like always, C is pretty liberal in not
securing its array boundaries. As you may jump ahead of the position with the largest
legal index, you are also allowed to jump before the opening index (0). Though C allows
you to do so, your run-time memory management system may be unhappy with your
unhealthy intrusion and may cause your program to have a premature termination (with
the error message "Segmentation fault"). It is the programmer's duty to insure that his/her
pointers do not roam around in prohibited areas.
Here is an example of a function that computes the sum of the elements of an array. The
naive method for doing so is:
int fooddition1 ( int A[] , int size ) { int i; int sum = 0; for (i=0; i<size; ++i) sum += A[i]; return (sum); }
The second method uses pointers:
int fooddition2 ( int A[] , int size ) { int i, *p; int sum = 0; p = A; /* Let p point to the 0-th location of A */ for (i=0; i<size; ++i) { sum += *p; /* Add to sum the element pointed to by p */ ++p; /* Let p point to the next location in A */ }
return (sum); }
Here is a third method that uses pointers in a subtler way:
int fooddition3 ( int A[] , int size ) { int i, *p; int sum = 0; p = A; for (i=0; i<size; ++i) sum += *(p + i); return (sum); }
Some key points need be highlighted now.
• Pointers are addresses in memory. If that is so, it apparently does not matter
whether it is a pointer to an int or a char or a double etc. But the pointer
arithmetic brings out the difference. Different data types require different amounts
of space. For example, an int typically requires 4 bytes, a char only one byte, a
double eight bytes, and so on. Now when you talk about the pointer p+i, the
actual memory address depends on how much (in bytes) one needs to advance p
in order to generate the i-th address. The amount of advance is dependent on the
data type that the pointer points to. For int pointers, p+i refers to 4i bytes ahead
in memory. For char pointers, this is just i bytes ahead of p. Finally, for double
pointers, p+i is 8i bytes ahead of p. The notation p+i is an abstraction that hides
the details of organization of data in the memory. You don't have to remember
how much space each data type requires. C will automatically advance your
pointers by appropriate amounts.
• Arrays and pointers are almost the same, but not identical. You can assign
addresses to pointers. But you are not allowed to do the same on arrays. An array
can only be declared, but cannot be assigned. Only the elements of an array can
be assigned values. For example, if we declare: • int A[MAXSIZE];
the following are not legal assignments:
A = &(A[2]); ++A;
However, statements like
p = A + i; sum += *(A + i);
are permitted, because they do not involve assignment of A.
• In a function declaration (or prototype) where an array need be passed, we can
pass a pointer instead. We have mentioned earlier that passing arrays to a function
does not copy the array element-by-element. It only passes the address of the (0-th
entry of the) array. We can substitute that by an explicit pointer. Here is how we
can rewrite the fooddition routine. • int fooddition4 ( int *A , int size )
• {
• int i = 0, sum = 0;
• while (i < size) {
• sum += *A;
• ++A;
• ++i;
• }
• return (sum);
• }
•
• int main ()
• {
• int A[5] = {3, 5, 7, 11, 13};
• int s;
•
• /* Compute the sum of all five elements of A */
• s = fooddition4(A,5);
•
• /* Compute the sum of the first through third elements of A */
• s = fooddition4(&A[1],3);
• }
The formal parameter A in fooddition4 is a pointer. It can be incremented (like
++A;). On the other hand, A refers to an array in main and so an increment like
++A; is not allowed in main.
Multi-dimensional arrays
One-dimensional arrays are quite able to represent many natural collections. There are
some other natural collections that may better be conceptualized as 2-dimensional data.
The first example is a matrix. What else can be a more natural 2-dimensional data other
than a matrix whose entries are natural numbers? So think of the following 4x5 matrix:
1 1 1 1 1 2 3 4 5 6 4 9 16 25 36 8 27 64 125 216
We can write the entries in the row-major order and represent the resulting flattened data
as a one-dimensional array:
1 1 1 1 1 2 3 4 5 6 4 9 16 25 36 8 27 64 125 216
As long as the column dimension of the matrix is known, the original matrix can be
recovered easily from this 1-D array. More precisely, consider an m-by-n matrix (a
matrix with m rows and n columns). It contains a total of mn elements. Let us number the
rows 0,1,...,m-1 from top to bottom and the columns 0,1,...,n-1 from left to right.
The entry at position (i,j) then maps to the (ni+j)-th entry of the one-dimensional
array. On the other hand, the k-th entry of the one-dimensional array corresponds to the
(i,j)-th element of the matrix, where i = k / n and j = k % n.
One-dimensional arrays suffice. Still, it is convenient and intuitive to visualize matrices
as two-dimensional arrays. C provides constructs to define and work with such arrays. Of
course, the memory of a computer is typically treated as a one-dimensional list of
memory cells. Any two-dimensional structure has to be flattened using a strategy like that
mentioned above. C handles this for you. In other words, the abstraction relieves you
from the task of doing the index arithmetic explicitly. You refer to the (i,j)-th element
as the (i,j)-th element. C translates it into the appropriate address in the one-
dimensional memory.
2-dimensional arrays can be defined like one-dimensional arrays, but with two square-
bracketed dimensions. For example, the declaration
int matrix[20][10];
allocates memory for a 20x10 array of int variables. The first index (20) indicates the
number of rows allocated, whereas the second indicates the number of columns allocated.
Elements of a 2-D array can be initialized to constant values using nested curly braces:
int mat[4][5] = { { 1, 1, 1, 1, 1 }, /* The zeroth row */ { 2, 3, 4, 5, 6 }, /* The first row */ { 4, 9, 16, 15, 25 }, /* The second row */ { 8, 27, 64, 125, 216 } /* The third row */ };
Rows of a 2-D array of characters can be initialized to constant strings.
char address[4][100] = { "Department of Foobarnautic Engineering", "Indian Institute of Technology", "Kharagpur 721302",
"India" };
For a 2-D array A the (i,j)-th element is treated as a variable and can be accessed by the
name A[i][j]. Both the row numbering and the column numbering start from 0. For
example, the (1,3)-th element of mat is accessed as mat[1][3] and, if initialized as
above, stores the int value 5.
Animation example : in-place transpose of a matrix
2-D arrays can be passed to functions using a syntax similar to the declaration of 2-D
arrays:
#define ROWDIM 10 #define COLDIM 12 int fooray ( int A[ROWDIM][COLDIM], int r , int c ) { ... }
Here the actual row and column dimensions of the used part of the array A are passed via
the parameters r and c. It is not mandatory to specify the row dimension ROWDIM. But the
column dimension COLDIM must be specified, since 2-D to 1-D mapping in memory
requires the column dimension. Thus the declaration
int fooray ( int A[][COLDIM], int r , int c ) { ... }
is allowed, whereas the declarations
int fooray ( int A[][], int r , int c ) { ... }
and
int fooray ( int A[ROWDIM][], int r , int c ) { ... }
are not allowed.
Like 1-D arrays, 2-D arrays are not copied element-by-element to functions. A pointer is
only passed. This implies that changes made to the array elements inside the function are
visible outside the function.
Indeed 2-D arrays are pointers too. However, these pointers are rather distinct in nature
from those pointers that represent 1-D arrays. The situation is quite clumsy and
confusing.
#define MAXROW 4 #define MAXCOL 5 int barsum ( int A[][MAXCOL] , int r , int c ) { int i, j, s; int (*p)[MAXCOL]; s = 0; p = A; for (i=0; i<r; ++i) for (j=0; j<c; ++j) s += p[i][j]; return s; }
The array A[][MAXCOL] can be assigned to the pointer p that should be declared as:
int (*p)[MAXCOL];
This declaration means that p is a pointer to an array of MAXCOL integers. The parentheses
surrounding *p are absolutely necessary for this. The declaration
int *p[MAXCOL];
won't work in this context. The reason is that the array indicator [] has higher precedence
than the pointer indicator *. Therefore, the last declaration is equivalent to
int *(p[MAXCOL]);
and means that p is an array of MAXCOL int pointers. This does not match the type of A.
In addition, this does not match the dimension of A.
There are four ways in which a 2-D array may be declared.
#define MAXROW 4 #define MAXCOL 5 int A[MAXROW][MAXCOL]; /* A is a statically allocated array */ int (*B)[MAXCOL]; /* B is a pointer to an array of MAXCOL integers */ int *C[MAXROW]; /* C is an array of MAXROW int pointers */ int **D; /* D is a pointer to an int pointer */
All these are essentially different in terms of memory management. Except the first array
A, the three other arrays support dynamic memory allocation. When properly allocated
memory, each of these can be used to represent a MAXROW-by-MAXCOL array.
Moreover, in all the four cases the (i,j)-th entry of the array is accessed as
Array_name[i][j]. The first two (A and B) are pointers to arrays, whereas the last two (C
and D) are arrays of pointers. The following figure elaborates this difference.
Figure: Two-dimensional arrays
We will discuss more about two-dimensional arrays in the chapter on dynamic memory
allocation.
Course home
CS13002 Programming and Data
Structures Spring
semester
Structures
Now it is time to combine heterogeneous data to form a named collection. For example,
think of a student's record that might comprise a name, a roll number, a height and a
CGPA. A name and a roll number are strings, a height (in cms, rounded to the nearest
integer) is an integer and a CGPA is a floating point value. A structure can be used to
combine these different types of data into a single item. Moreover, each constituent field
in the composite data is made individually accessible. What we benefit from using
structures is a convenient and logical way of looking at and arranging data. That's the
basic motivation behind every abstraction.
Defining structures
Structures can be defined by the struct keyword. For example, a student's record can be
After this definition one can declare individual nodes like:
treenode thatNode, leaf[100];
One can declare pointers to nodes in the usual way:
treenode *root;
or by using other type definitions:
typedef treenode *tnptr; tnptr root;
We will shortly use such linked structures with dynamic memory allocation for realizing
several useful (abstract) objects.
Unions
Suppose we want to make a list of nodes. Each node in the list may be one of two
possible types: a data node and a control node. Suppose further that a data node stores an
int, whereas a control node stores a control information that can be specified by a 16-
character string. A structure like the following can be used:
struct foonode { int data; char control[16]; } thisNode, fooArray[1024];
The problem with this is that irrespective of whether a node is a control node or a data
node, the structure requires space for both the data and the control string. A data node
does not use the control string at all, and similarly a control node does not require the
data. That leads to unnecessary waste of space. In order to reduce the space requirement
of each node, we should use a union instead of a struct.
union barnode { int data; char control[16]; } thisNode, barArray[1024];
In this case the compiler reserves the space that is sufficient to store the biggest of the
individual members. For example, the int member requires 4 bytes, whereas the control
string requires 16 bytes. For the struct foonode the compiler uses 20 bytes of memory.
For the union barnode, on the other hand, a memory of only 16 bytes is allocated. That
memory (more correctly, a part of it) can be used as an integer variable or as a character
string. In other words, the members of a union occupy overlapping space. When we say
thatNode.data or barArray[51].data, the content of the memory is interpreted as an
integer, whereas thatNode.control or barArray[51].control refers to a character
string.
This may seem confusing initially, because it is not clear what data is actually stored in
the memory. Interpreting a character string as an integer need not always make sense, and
vice versa. The information regarding what kind of data a union stores is to be maintained
externally, i.e., outside the union. One possibility is to use unions in conjunction with
structures.
#define DATA_NODE 0 #define CONTROL_NODE 1 struct foobarnode { int what; /* can be either DATA_NODE or CONTROL_NODE */ union { int data; char control[16]; } info; } thatNode, foobarArray[1024];
This structure stores the type of the node and then the union of an integer and a character
string. Depending on the value of what, the programmer is to interpret the type of the
node. If what is set to DATA_NODE, one should use the union info as an integer data and
access this as thatNode.info.data or as foobarArray[131].info.data. On the other
hand, if what is set to CONTROL_NODE, one should use the union as a character string that
can be accessed as thatNode.info.control or as foobarArray[131].info.control.
Here is another example, in which a node contains a union of three different kinds of
data.
#include <stdio.h> typedef struct _foostruct { int intArray[512]; double dblArray[128]; char chrArray[1024]; struct _foostruct *next; } foostruct; typedef struct _barstruct { int type; union { int intArray[512]; double dblArray[128]; char chrArray[1024]; } data; struct _barstruct *next; } barstruct; int main () { printf("sizeof(foostruct) = %d\n", sizeof(foostruct)); printf("sizeof(barstruct) = %d\n", sizeof(barstruct)); }
In my machine, this program outputs:
sizeof(foostruct) = 4100 sizeof(barstruct) = 2056
Look at the space saving effected by using the union. Note also that the next pointer
should be there in every node irrespective of its type. That is why this pointer should be
declared outside the union.
Course home
CS13002 Programming and Data
Structures Spring
semester
Pointers and dynamic memory allocation
All variables, arrays, structures and unions that we worked with so far are statically
allocated, meaning that whenever an appropriate scope is entered (e.g. a function is
invoked) an amount of memory dependent on the data types and sizes is allocated from
the stack area of the memory. When the program goes out of the scope (e.g. when a
function returns), this memory is returned back to the stack. There is an alternative way
of allocating memory, more precisely, from the heap part of the memory. In this case, the
user makes specific calls to capture some amount of memory and continues to hold that
memory unless it is explicitly (i.e., by distinguished calls) returned back to the heap. Such
memory is said to be dynamically allocated.
In order to exemplify the usefulness of dynamic memory allocation, suppose that there
are two types of foomatic collections: the first type refers to an array of ten integers,
whereas the second type refers to an array of ten million integers. A foomatic chain is
made from a combination of one million collections of first type and few (say, ten)
collections of the second type. Such a chain demands a total memory capable of holding
110 million integers. Assuming that an integer is of size 32 bits, this amounts to a
memory of 440 Megabytes. A modern personal computer usually has enough memory to
accommodate this data.
It is a foomatic convention to treat both types of collection uniformly, i.e., our plan is to
represent both by a single data type. Think of the difference between you and me. I am
the instructor (synonymously the president) of the class, whereas students are only
listeners (synonymous with citizens). A president is the most important person in a
society, he requires microphones, computers, bla bla bla. Still, both the president and
each citizen are of the same data type called human.
So a foollection is a foomatic human capable of representing a collection of either type.
If we plan to handle it using a structure with an array (or union), we must prepare for the
bigger collections. The definition goes like this:
typedef struct { int type; int data[10000000]; } foollection;
Now irrespective of what a foollection data actually stores, it requires memory for ten
million and one integers. (Think of each of you being given a PA system and a computer
in the class.) A foomatic chain then requires over 40,000 Gigabytes of memory. This is
sheer waste of space, since only 440 Megabytes suffice. Moreover, no personal computer
I have heard of comes with so much memory including hard disks.
What is the way out? Let us plan to redefine foollection in the following way:
typedef struct { int type; int *data; } foollection;
I have replaced the static array by a pointer. We will soon see that a pointer can be
allocated memory from the heap and that the amount of memory to be allocated to each
pointer can be specified during the execution of the program. Thus the data pointer in a
foollection variable is assigned exactly as much memory as is needed. (It is as if when
I come to the classroom, the run-time system gives me a PA system and a computer,
whereas a student is given only a comfortable chair.) Now each collection requires, in
addition to the actual data array, the space for an int variable and for a pointer, typically
demanding 4 bytes each. So a foomatic chain requires a space overhead of slightly more
than 8 Megabytes, i.e., a chain with all foomatic abstractions now fits in a memory of size
less than 450 Megabytes. My computer has this much space.
Let me illustrate another situation where dynamic memory allocation proves to be
extremely useful. Look at lists and trees made up of structures with self-referencing
pointers:
Figure: Dynamic lists
A static array can implement such lists, but has two disadvantages:
• The size of a static array is fixed during declaration, i.e., a static array can handle
lists of a bounded size. Even if my machine has more memory than yours, I
cannot leverage this superiority of my computer with static arrays. On the other
extreme, irrespective of the actual size of the collection, a static array necessarily
consumes the entire space for the biggest supportable collection.
• The linked structure can be incorporated in the framework of an array, but that
requires (often awful) calculations to find the locations of the next objects. If
pointers with dynamically assigned memory are used, accessing objects following
the links becomes much easier.
So there is a big bunch of reasons why we should jump for dynamic memory
management. Do it. But listen to the standard good advice from me. Dynamic memory
allocation gives a programmer too much control of memory. Inexperienced programmers
do not know how to effectively exploit that control. There remains every chance that
everything gets repeatedly goofed up and the programmer, tired of fighting with
segmentation faults for weeks, eventually gives up and joins the ice-cream industry. If
you excel in this new job, I won't mind, even given that I am not a particular fan of ice-
creams. But my job is to teach you programming, not how to manufacture tasty ice-
creams.
One-dimensional dynamic memory
The built-in function malloc allocates a one-dimensional array to a pointer. You have to
specify the total amount of memory (in bytes) that you would like to allocate to the
pointer.
#define SIZE1 25 #define SIZE2 36 int *p; long double *q; p = (int *)malloc(SIZE1 * sizeof(int)); q = (long double *)malloc(SIZE2 * sizeof(long double));
The first call of malloc allocates to p a (dynamic) array capable of storing SIZE1
integers. The second call allocates an array of SIZE2 long double data to the pointer q.
In addition to the size of each array, we need to specify the sizeof (size in bytes of) the
underlying data type. malloc allocates memory in bytes and reads the amount of bytes
needed from its sole argument.
If you demand more memory than is currently available in your system, malloc returns
the NULL pointer. So checking the allocated pointer for NULLity is the way how one can
check if the allocation request has been successfully processed by the memory
management system.
malloc allocates raw memory from some place in the heap. No attempts are made to
initialize that memory. It is the programmer's duty to initialize and then use the values
stored at the locations of a dynamic array.
Animation example : 1-D dynamic memory
Example: Let us now write a function that allocates an appropriate amount of memory to
a foollection structure based on the type of the collection it is going to represent.
foollection initfc ( int type ) { foollection fc; /* Set type of the collection */ fc.type = type; /* Allocate memory for the data pointer */ if (type == 1) fc.data = (int *)malloc(10*sizeof(int)); else if (type == 2) fc.data = (int *)malloc(10000000*sizeof(int)); else fc.data = NULL; /* Check for error conditions */ if (fc.data == NULL) fprintf(stderr, "Error: insufficient memory or unknown type.\n"); return fc; }
Example: Let us now create a linked list of 4 nodes holding the integer values 3,5,7,9
from start to end. For simplicity we do not check for error conditions.
typedef struct _node { int data; struct _node *next; } node; node *head, *p; int i; head = (node *)malloc(sizeof(node)); /* Create the first node */ head->data = 3; /* Set data for the first node */ p = head; /* Next p will navigate down the list */ for (i=1; i<=3; ++i) { p->next = (node *)malloc(sizeof(node)); /* Allocate the next node */ p = p->next; /* Advance p by one node */ p->data = 2*i+3; /* Set data */ } p->next = NULL; /* Terminate the list by NULL */
An important thing to notice here is that we are always allocating memory to p->next
and not to p itself. For example, first consider the allocation of head and subsequently an
allocation of p assigned to head->next.
head = (node *)malloc(sizeof(node));
p = head->next; p = (node *)malloc(sizeof(node));
After the first assignment of p, both this pointer and the next pointer of *head point to
the same location. However, they continue to remain different pointers. Therefore, the
subsequent memory allocation of p changes p, whereas head->next remains unaffected.
For maintaining the list structure we, on the other hand, want head->next to be allocated
memory. So allocating the running pointer p is an error. One should allocate p->next
with p assigned to head (not to head->next). Now p and head point to the same node
and, therefore, both p->next and head->next refer to the same pointer -- the one to
which we like to allocate memory in the subsequent step.
This example illustrates that the first node is to be treated separately from subsequent
nodes. This is the reason why we often maintain a dummy node at the head and start the
actual data list from the next node. We will see many examples of this convention later in
this course.
There are two other ways by which memory can be allocated to pointers. The calloc call
takes two arguments, a number n of cells and a size s of a data, and returns an allocated
array capable of storing n objects each of size s. Moreover, the allocated memory is
initialized to zero. If the allocation request fails, the NULL pointer is returned.
#define FOO_CHAIN_SIZE 1000000 typedef struct { int type; int *data; } foollection; foollection *foochain; foochain = (foollection *)calloc(FOO_CHAIN_SIZE,sizeof(foollection));
This call creates an array of one million foollection structures (or NULL if the machine
cannot provide the requested amount of memory). Each structure in the array is initialized
to zero, i.e., each foochain[i].type is set to 0 and each foochain[i].data is set to
NULL.
The realloc call reallocates memory to a pointer. It is essentially used to change the
amount of memory allocated to some pointer. If the new size s' of the memory is larger
than the older size s, then s bytes are copied from the old memory to the new memory.
The remaining s'-s bytes are left uninitialized. On the contrary, if s'<s, then only s'
bytes are copied. If the reallocation request fails, the original pointer remains unchanged
and the NULL pointer is returned.
As an example, suppose that we want to change the size of the dynamic array pointed to
by foochain from one million to two millions, but without altering the data currently
stored in the array. We can use the following call:
#define NEW_SIZE 2000000 foochain = realloc(foochain, NEW_SIZE * sizeof(foollection)); if (foochain == NULL) fprintf(stderr, "Error: unable to reallocate storage.\n");
Memory allocated by malloc, calloc or realloc can be returned to the heap by the
free system call. It takes an allocated pointer as argument. For example, the foochain
pointer can be deallocated memory by the call:
free(foochain);
When a program terminates, all allocated memory (static and dynamic) is returned to the
system. There is no necessity to free memory explicitly. However, since memory is a
bounded resource, allocating it several times, say, inside a loop, may eventually let the
system run out of memory. So it is a good programming practice to free memory that will
no longer be used in the program.
Two-dimensional dynamic memory
Allocating two-dimensional memory is fundamentally similar to allocating one-
dimensional memory. One uses the same calls (malloc, etc.) described in the previous
section. One should only be careful about the allocation sizes and the return types.
Recall that we have four ways of declaring two-dimensional arrays. These are
summarized below:
#define ROWSIZE 100 #define COLSIZE 200 int A[ROWSIZE][COLSIZE]; int (*B)[COLSIZE]; int *C[ROWSIZE]; int **D;
The first array A is fully static. It cannot be allocated or deallocated memory dynamically.
As the definition of A is encountered, the required amount of space is allocated to A from
the stack area of the memory. When the definition of A expires (i.e., the scope of A ends,
say, due to return from a function or exit from a block), the static memory is returned
back to the stack. Each of the three other arrays (B,C,D) has a dynamic component in it.
Let us study them case-by-case.
B is a pointer to an array of COLSIZE integers. So it can be allocated ROWSIZE rows in the
following way:
B = (int (*)[COLSIZE])malloc(ROWSIZE * sizeof(int[COLSIZE]));
The same can be achieved in a more readable way as follows:
typedef int matrow[COLSIZE]; B = (matrow *)malloc(ROWSIZE * sizeof(matrow));
C is a static array of ROWSIZE int pointers. Therefore, C itself cannot be allocated or
deallocated memory. The individual rows of C should be allocated memory.
int i; for (i=0; i<ROWSIZE; ++i) C[i] = (int *)malloc(COLSIZE * sizeof(int));
D is dynamic in both directions. First, it should be allocated memory to store ROWSIZE
int pointers each meant for a row of the 2-D array. Each row pointer, in turn, should be
allocated memory for COLSIZE int data.
int i; D = (int **)malloc(ROWSIZE * sizeof(int *)); for (i=0; i<ROWSIZE; ++i) D[i] = (int *)malloc(COLSIZE * sizeof(int));
The last two pointers C,D allow rows of different sizes, since each row is allocated
memory individually.
That's all! It may be somewhat confusing to understand the differences among these four
cases. Things become clearer once you realize what type of pointer each of A,B,C,D is.
Animation example : 2-D dynamic memory
Though the internal organizations of these arrays are quite different in the memory, their
access mechanism is the same in the sense that the same notation Array_name[i][j]
refers to the i,j-th entry in each of the four arrays. In order to promote this uniformity,
the C compiler has to be quite fussy about the types of these arrays. Typecasting among
these four types is often a crime that may result in mild warnings to failure of compilation
to segmentation faults. Take sufficient care. Beware of the ice-cream industry!
The freeing mechanism is also different for the four arrays.
int i; /* A is a static array and cannot be free'd */ /* B is a single pointer */
free(B); /* C is a static array of pointers each to be free'd individually */ for (i=0; i<ROWSIZE; ++i) free(C[i]); /* Free each row */ /* D is a pointer to pointers. Each of these pointers is to be free'd */ for (i=0; i<ROWSIZE; ++i) free(D[i]); /* Free each row */ free(D); /* Free the row top */
I think it suffices to learn to work with only the completely static (A) and the completely
dynamic (D) versions of 2-D arrays. They are my personal favorites and any-time
recommendations.
Still, if you care, here follows a program that shows you the internal organizations of
each memory cell and each row header for these four kinds of arrays. The addresses are
displayed as byte distances relative to the header of the entire matrix.
#include <stdio.h> #define ROWSIZE 4 #define COLSIZE 5 int A[ROWSIZE][COLSIZE]; int (*B)[COLSIZE]; int *C[ROWSIZE]; int **D; int main () { int i, j; printf("\nArray A\n"); printf("sizeof(*A) = %d\n",sizeof(*A)); printf(" j=0 j=1 j=2 j=3 j=4\n"); printf(" +-------------------------------+\n"); for (i=0; i<ROWSIZE; ++i) { printf("A[%d] = %4d : i=%d |", i, (int)A[i]-(int)A, i); for (j=0; j<COLSIZE; ++j) printf("%6d", (int)(&A[i][j])-(int)A); printf(" |\n"); } printf(" +-------------------------------+\n"); printf("\nArray B\n"); B = (int (*)[COLSIZE])malloc(ROWSIZE * sizeof(int[COLSIZE])); printf("sizeof(*B) = %d\n",sizeof(*B)); printf(" j=0 j=1 j=2 j=3 j=4\n"); printf(" +-------------------------------+\n"); for (i=0; i<ROWSIZE; ++i) { printf("B[%d] = %4d : i=%d |", i, (int)B[i]-(int)B, i); for (j=0; j<COLSIZE; ++j)
Let us now implement all the associated functions one by one. olist init () { olist L; L.len = 0; return L; } olist insert ( olist L , char ch , int pos ) { int i; if ((pos < 0) || (pos > L.len)) { fprintf(stderr, "insert: Invalid index %d\n", pos); return L; } if (L.len == MAXLEN) { fprintf(stderr, "insert: List already full\n"); return L; } for (i = L.len; i > pos; --i) L.element[i] = L.element[i-1]; L.element[pos] = ch; ++L.len; return L; } olist delete ( olist L , int pos ) { int i; if ((pos < 0) || (pos >= L.len)) { fprintf(stderr, "delete: Invalid index %d\n", pos); return L; } for (i = pos; i <= L.len - 2; ++i) L.element[i] = L.element[i+1]; --L.len; return L; } int isPresent ( olist L , char ch ) { int i; for (i = 0; i < L.len; ++i) if (L.element[i] == ch) return i; return -1;
} char getElement ( olist L , int pos ) { if ((pos < 0) || (pos >= L.len)) { fprintf(stderr, "getElement: Invalid index %d\n", pos); return '\0'; } return L.element[pos]; } void print ( olist L ) { int i; for (i = 0; i < L.len; ++i) printf("%c", L.element[i]); }
Here is a possible main() function with these calls.
int main () { olist L; L = init(); L = insert(L,'a',0); printf("Current list is : "); print(L); printf("\n"); L = insert(L,'b',0); printf("Current list is : "); print(L); printf("\n"); L = delete(L,5); printf("Current list is : "); print(L); printf("\n"); L = insert(L,'c',1); printf("Current list is : "); print(L); printf("\n"); L = insert(L,'b',3); printf("Current list is : "); print(L); printf("\n"); L = delete(L,2); printf("Current list is : "); print(L); printf("\n"); L = insert(L,'z',8); printf("Current list is : "); print(L); printf("\n"); L = delete(L,2); printf("Current list is : "); print(L); printf("\n"); printf("Element at position 1 is %c\n", getElement(L,1)); }
Here is the complete program.
Animation example : Implementation of the ordered list ADT with static
memory
Implementation using linked lists
Let us now see an implementation based on dynamic linked lists. We use the same
prototypes for function calls. But we define the basic data type olist in a separate
manner. For the sake of ease of writing the functions, we maintain a dummy node at the
beginning of the linked list.
typedef struct _node { char element; struct _node *next; } node; typedef node *olist; olist init () { olist L; /* Create the dummy node */ L = (node *)malloc(sizeof(node)); L -> element = '\0'; L -> next = NULL; return L; } olist insert ( olist L , char ch , int pos ) { int i; node *p, *n; if (pos < 0) { fprintf(stderr, "insert: Invalid index %d\n", pos); return L; } p = L; i = 0; while (i < pos) { p = p -> next; if (p == NULL) { fprintf(stderr, "insert: Invalid index %d\n", pos); return L; } ++i; } n = (node *)malloc(sizeof(node)); n -> element = ch; n -> next = p -> next; p -> next = n; return L; } olist delete ( olist L , int pos ) { int i; node *p; if (pos < 0) { fprintf(stderr, "delete: Invalid index %d\n", pos);
return L; } p = L; i = 0; while ((i < pos) && (p -> next != NULL)) { p = p -> next; ++i; } if (p -> next == NULL) { fprintf(stderr, "delete: Invalid index %d\n", pos); return L; } p -> next = p -> next -> next; return L; } int isPresent ( olist L , char ch ) { int i; node *p; i = 0; p = L -> next; while (p != NULL) { if (p -> element == ch) return i; p = p -> next; ++i; } return -1; } char getElement ( olist L , int pos ) { int i; node *p; i = 0; p = L -> next; while ((i < pos) && (p != NULL)) { p = p -> next; ++i; } if (p == NULL) { fprintf(stderr, "getElement: Invalid index %d\n", pos); return '\0'; } return p -> element; } void print ( olist L ) { node *p; p = L -> next; while (p != NULL) { printf("%c", p -> element); p = p -> next;
} }
The main() function of the static array implementation can be used without any change
under this implementation. Here is the complete program.
Animation example : Implementation of the ordered list ADT with
dynamic memory
This exemplifies that the abstract properties and functional behaviors are independent of
the actual implementation, or stated in another way, our two implementations of the
ordered list ADT correctly and consistently tally with the abstract specification.
And why should we stop here? There could be thousand other ways in which the same
ADT can be implemented, and in all these cases the function prototypes may be so
chosen that the same main() function will work. This is the precise difference between
an abstract specification and particular implementations.
Course home
CS13002 Programming and Data
Structures Spring
semester
Stacks and queues
Stacks and queues are special kinds of ordered lists in which insertion and deletion are
restricted only to some specific positions. They are very important tools for solving many
useful computational problems. Since we have already implemented ordered lists in the
most general form, we can use these to implement stacks and queues. However, because
of the special insertion and deletion patterns for stacks and queues, the ADT functions
can be written to be much more efficient than the general functions. Given the
importance of these new ADTs, it is worthwhile to devote time to these special
implementations.
The stack ADT and its applications
A stack is an ordered list of elements in which elements are always inserted and deleted
at one end, say the beginning. In the terminology of stacks, this end is called the top of
the stack, whereas the other end is called the bottom of the stack. Also the insertion
operation is called push and the deletion operation is called pop. The element at the top
of a stack is frequently referred, so we highlight this special form of getElement.
A stack ADT can be specified by the following basic operations. Once again we assume
that we are maintaining a stack of characters. In practice, the data type for each element
of a stack can be of any data type. Characters are chosen as place-holders for simplicity.
S = init();
Initialize S to an empty stack. isEmpty(S);
Returns "true" if and only if the stack S is empty, i.e., contains no elements. isFull(S);
Returns "true" if and only if the stack S has a bounded size and holds the
maximum number of elements it can. top(S);
Return the element at the top of the stack S, or error if the stack is empty. S = push(S,ch);
Push the character ch at the top of the stack S. S = pop(S);
Pop an element from the top of the stack S. print(S);
Print the elements of the stack S from top to bottom.
An element popped out of the stack is always the last element to have been pushed in.
Therefore, a stack is often called a Last-In-First-Out or a LIFO list.
Applications of stacks
Stacks are used in a variety of applications. While some of these applications are
"natural", most other are essentially "pedantic". Here is a list anyway.
• For processing nested structures, like checking for balanced parentheses,
evaluation of postfix expressions.
• For handling function calls and, in particular, recursion.
• For searching in special data structures (depth-first search in graphs and trees), for
example, for implementing backtracking.
Animation example : Use of stacks to evaluate postfix expressions
Interactive animation : Use of stacks to evaluate postfix expressions
Implementations of the stack ADT
A stack is specified by the ordered collection representing the content of the stack
together with the choice of the end of the collection to be treated as the top. The top
should be so chosen that pushing and popping can be made as far efficient as possible.
Using static arrays
Static arrays can realize stacks of a maximum possible size. If we assume that the stack
elements are stored in the array starting from the index 0, it is convenient to take the top
as the maximum index of an element in the array. Of course, the other choice, i.e., the
other boundary 0, can in principle be treated as the top, but insertions and deletions at the
location 0 call for too many relocations of array elements. So our original choice is
} int isFull ( stack S ) { return (S.top == MAXLEN - 1); } char top ( stack S ) { if (isEmpty(S)) { fprintf(stderr, "top: Empty stack\n"); return '\0'; } return S.element[S.top]; } stack push ( stack S , char ch ) { if (isFull(S)) { fprintf(stderr, "push: Full stack\n"); return S; } ++S.top; S.element[S.top] = ch; return S; } stack pop ( stack S ) { if (isEmpty(S)) { fprintf(stderr, "pop: Empty stack\n"); return S; } --S.top; return S; } void print ( stack S ) { int i; for (i = S.top; i >= 0; --i) printf("%c",S.element[i]); }
Here is a possible main() function calling these routines:
int main () { stack S; S = init(); printf("Current stack : "); print(S); printf(" with top = %c.\n", top(S)); S = push(S,'d'); printf("Current stack : "); print(S); printf(" with top = %c.\n", top(S)); S = push(S,'f'); printf("Current stack : "); print(S); printf(" with top = %c.\n", top(S));
S = push(S,'a'); printf("Current stack : "); print(S); printf(" with top = %c.\n", top(S)); S = pop(S); printf("Current stack : "); print(S); printf(" with top = %c.\n", top(S)); S = push(S,'x'); printf("Current stack : "); print(S); printf(" with top = %c.\n", top(S)); S = pop(S); printf("Current stack : "); print(S); printf(" with top = %c.\n", top(S)); S = pop(S); printf("Current stack : "); print(S); printf(" with top = %c.\n", top(S)); S = pop(S); printf("Current stack : "); print(S); printf(" with top = %c.\n", top(S)); S = pop(S); printf("Current stack : "); print(S); printf(" with top = %c.\n", top(S)); }
Here is the complete program. The output of the program is given below:
top: Empty stack Current stack : with top = . Current stack : d with top = d. Current stack : fd with top = f. Current stack : afd with top = a. Current stack : fd with top = f. Current stack : xfd with top = x. Current stack : fd with top = f. Current stack : d with top = d. top: Empty stack Current stack : with top = . pop: Empty stack top: Empty stack Current stack : with top = .
Animation example : Implementation of stacks with static memory
Using dynamic linked lists
As we have seen earlier, it is no big deal to create and maintain a dynamic list of
elements. The only consideration now is to decide whether the beginning or the end of
the list is to be treated as the top of the stack. Deletion becomes costly, if we choose the
end of the list as the top. Choosing the beginning as the top makes the implementations of
both push and pop easy. So we stick to this convention. As usual, we maintain a dummy
node at the top (beginning) for simplifying certain operations. The ADT functions are
{ stack S; /* Create the dummy node */ S = (node *)malloc(sizeof(node)); S -> element = '\0'; S -> next = NULL; return S; } int isEmpty ( stack S ) { return (S -> next == NULL); } int isFull ( stack S ) { /* With dynamic memory the stack never gets full. However, a new allocation request may fail because of memory limitations. That may better be checked immediately after each malloc statement is executed. For simplicity we avoid this check in this implementation. */ return 0; } char top ( stack S ) { if (isEmpty(S)) { fprintf(stderr, "top: Empty stack\n"); return '\0'; } return S -> next -> element; } stack push ( stack S , char ch ) { node *T; if (isFull(S)) { fprintf(stderr, "push: Full stack\n"); return S; } /* Copy the new element in the dummy node */ S -> element = ch; /* Create a new dummy node */ T = (node *)malloc(sizeof(node)); T -> element = '\0'; T -> next = S; return T; } stack pop ( stack S ) { if (isEmpty(S)) {
fprintf(stderr, "pop: Empty stack\n"); return S; } /* Treat the stack top as the new dummy node */ S -> next -> element = '\0'; return S -> next; } void print ( stack S ) { node *T; T = S -> next; while (T != NULL) { printf("%c", T -> element); T = T -> next; } }
These new functions are compatible with the main() function of the implementation
using arrays. The complete program is here.
Animation example : Implementation of stacks with dynamic linked lists
The queue ADT and its applications
A queue is like a "natural" queue of elements. It is an ordered list in which all insertions
occur at one end called the back or rear of the queue, whereas all deletions occur at the
other end called the front or head of the queue. In the popular terminology, insertion and
deletion in a queue are respectively called the enqueue and the dequeue operations. The
element dequeued from a queue is always the first to have been enqueued among the
elements currently present in the queue. In view of this, a queue is often called a First-
In-First-Out or a FIFO list.
The following functions specify the operations on the queue ADT. We are going to
maintain a queue of characters. In practice, each element of a queue can be of any well-
defined data type.
Q = init();
Initialize the queue Q to the empty queue. isEmpty(Q);
Returns "true" if and only if the queue Q is empty. isFull(Q);
Returns "true" if and only if the queue Q is full, provided that we impose a limit
on the maximum size of the queue. front(Q);
Returns the element at the front of the queue Q or error if the queue is empty. Q = enqueue(Q,ch);
Inserts the element ch at the back of the queue Q. Insertion request in a full queue
should lead to failure together with some appropriate error messages. Q = dequeue(Q);
Delete one element from the front of the queue Q. A dequeue attempt from an
empty queue should lead to failure and appropriate error messages. print(Q);
Print the elements of the queue Q from front to back.
Applications of queues
• For implementing any "natural" FIFO service, like telephone enquiries,
reservation requests, traffic flow, etc.
• For implementing any "computational" FIFO service, for instance, to access some
resources. Examples: printer queues, disk queues, etc.
• For searching in special data structures (breadth-first search in graphs and trees).
• For handling scheduling of processes in a multitasking operating system.
Animation example : Use of queues for round-robin scheduling
Implementations of the queue ADT
Continuing with our standard practice followed so far, we are going to provide two
implementations of the queue ADT, the first using static memory, the second using
dynamic memory. The implementations aim at optimizing both the insertion and deletion
operations.
Using static arrays
Recall that in our implementation of the "ordered list" ADT we always let the list start
from the array index 0. This calls for relocation of elements of the list in the supporting
array after certain operations (usually deletion). Now we plan to exploit the specific
insertion and deletion patterns in queues to avoid these costly relocations.
We maintain two indices to represent the front and the back of the queue. During an
enqueue operation, the back index is incremented and the new element is written in this
location. For a dequeue operation, on the other hand, the front is simply advanced by one
position. It then follows that the entire queue now moves down the array and the back
index may hit the right end of the array, even when the size of the queue is smaller than
the capacity of the array.
In order to avoid waste of space, we allow our queue to wrap at the end. This means that
after the back pointer reaches the end of the array and needs to proceed further down the
line, it comes back to the zeroth index, provided that there is space at the beginning of the
array to accommodate new elements. Thus, the array is now treated as a circular one with
index MAXLEN treated as 0, MAXLEN + 1 as 1, and so on. That is, index calculation is done
modulo MAXLEN. We still don't have to maintain the total queue size. As soon as the back
index attempts to collide with the front index modulo MAXLEN, the array is considered to
be full.
There is just one more problem to solve. A little thought reveals that under this wrap-
around technology, there is no difference between a full queue and an empty queue with
respect to arithmetic modulo MAXLEN. This problem can be tackled if we allow the queue
to grow to a maximum size of MAXLEN - 1. This means we are going to lose one
available space, but that loss is inconsequential. Now the condition for full array is that
the front index is two locations ahead of the back modulo MAXLEN, whereas the empty
array is characterized by that the front index is just one position ahead of the back again
modulo MAXLEN.
An implementation of the queue ADT under these design principles is now given.
Finally, this is the output of the complete program.
Current queue : Current queue : h Current queue : hw Current queue : hwr Current queue : wr Current queue : r Current queue : rc Current queue : c Current queue : dequeue: Queue is empty Current queue :
Animation example : Implementation of queues with static memory
Using dynamic linked lists
Linked lists can be used for implementing queues. We plan to maintain a dummy node at
the beginning and two pointers, the first pointing to this dummy node and the second
pointing to the last element. Both insertion and deletion are easy at the beginning.
Insertion is easy at the end, but deletion is difficult at the end, since we have to move the
pointer at the end one step back and there is no way other than traversing the entire list in
order to trace the new end. So the natural choice is to take the beginning of the linked list
as the front of the queue and the end of the list as the back of the queue.
The corresponding implementation is detailed below:
The first inequality shows that T(n) cannot have polynomial order, whereas the
second inequality shows that T(n) is of exponential order.
To sum up, recursion helped us convert a polynomial-time (in fact, linear)
algorithm to a truly exponential algorithm. This teaches you two lessons. First,
use recursion judiciously. Second, different algorithms (or implementations) for
the same problem may have widely different complexities. Performance analysis
of programs is really important then!
• Linear search
We are given an array A of n integers and another integer x. The task is to locate
the existence of x in A. Here n is taken to be the input size. We assume that A is
not sorted, i.e., we will do linear search in the array. Here is the code:
int linSearch ( int A[] , int n , int x )
{
int i;
for (i=0; i<n; ++i) if (A[i] == x) return 1;
return 0;
}
The time complexity of the above function depends on whether x is present in A
and if so at which location. Clearly, the worst case (longest running time) occurs
when x is not present in the array and the last statement (return 0;) is executed.
In this case the loop requires one initialization of i, n increments of i and n+1
comparisons of i with n. Inside the loop body there is a single comparison which
fails in all of the n iterations of the loop in the worst-case scenario. Thus the total
time needed by this function is:
1 + n + (n+1) + n = 3n + 2.
This is O(n), i.e., the linear search is a linear time algorithm.
• Binary search
In order to curtail the running time of linear search, one uses the binary search
algorithm. This requires the array A to be sorted a priori. We do not compute the
running time for sorting now, but look at the running time of binary search in a
sorted array.
int binSearch ( int A[] , int n , int x )
{
int L, R, M;
L = 0; R = n-1;
while (L < R) {
M = (L + R) / 2;
if (x > A[M]) L = M+1; else R = M;
}
return (A[L] == x);
}
For simplicity assume that the array size n is a power of 2, i.e., n = 2k for some
integer k >= 0. Initially, the boundaries L and R are adjusted to the leftmost and
rightmost indices of the entire array. After each iteration of the while loop the
central index M of the current search window is computed. Depending on the
result of comparison of x with A[M], the boundaries (L,R) is changed either to
(L,M) or to (M+1,R). In either case, the size of the search window (i.e., the
subarray delimited by L and R) is reduced to half. Thus after k iterations of the
while loop the search window reduces to a subarray of size 1, and L and R
become equal. After the loop terminates, a comparison is made between x and an
array element. So the number of basic operations done by this algorithm equals:
2 + (k+1) + k x (2 + 1 + 1) +
1
(Init) (Loop condn) (No of iter) (ops in loop body)
(last comparison)
= 5k + 4.
But k = log2n, so the running time of binary search is O(log n), i.e.,
logarithmic. This is far better than the linear running time of the linear search
algorithm.
• Bubble sort
It is interesting to look at the running times of different sorting algorithms. Let us
start with a non-recursive sorting algorithm. Here is the code that bubble sorts an
array of size n.
void bubbleSort ( int A[] , int n )
{
for (i=n-2; i>=0; --i) {
for (j=0; j<=i; ++j) {
if (A[j] > A[j+1]) {
t = A[j];
A[j] = A[j+1];
A[j+1] = t;
}
}
}
}
This is an example of a nested for loop. The outer loop runs over i for the values
n-2,n-3,...,0 and for a value of i the inner loop is executed i+1 times. This
means that the inner loop is executed a total number of
(n-1) + (n-2) + ... + 2 + 1 = n(n-1)/2
times. Each iteration of the inner loop involves a comparison and conditionally a
set of three assignment operations. Thus the inner loop performs at most
4 x n(n-1)/2 = 2n(n-1)
basic operations. This quantity is O(n2). We should also add the costs associated
with the maintenance of the loops. The outer loop requires O(n) time, whereas for
each i the inner loop requires O(i) time. The n-1 iterations of the outer loop then
leads to a total of O((n-1) + (n-2) + ... + 1), i.e., O(n2), basic operations
for maintaining all of the inner loops. To sum up, we conclude that the bubble sort
algorithm runs in O(n2) time.
• Matrix multiplication
Here is the straightforward code for multiplying two n x n matrices. We take n as
the input size parameter.
/* Multiply two n x n matrices A and B and store the product
in C */
void matMul ( int C[SIZE][SIZE] , int A[SIZE][SIZE] , int
B[SIZE][SIZE] , int n )
{
int i, j, k;
for (i=0; i<n; ++i) {
for (j=0; j<n; ++j) {
C[i][j] = 0;
for (k=0; k<n; ++k) C[i][j] += A[i][k] * B[k][j];
}
}
}
This is another example of nested loops with an additional level of nesting
(compared to bubble sort). The outermost and the intermediate loops run
independently over the values of i and j in the range 0,1,...,n-1. For each of
these n2 possible values of i,j, the element C[i][j] is first initialized and then
the innermost loop on k is executed exactly n times. Each iteration in the
innermost loop involves one multiplication and one addition. Therefore, for each
i,j the innermost loop takes O(n) running time. This is also the cost associated
with maintaining the loop on k. Thus each execution of the body of the
intermediate loop takes a total of O(n) time and this body is executed n2 times
leading to a total running time of O(n3). It is easy to argue that the cost for
maintaining the loop on i is O(n) and that for maintaining all of the n executions
of the intermediate loop is O(n2).
So two n x n matrices can be multiplied in O(n3) time. Can we make any better
than that? The answer is: yes. There are algorithms that multiply two n x n
matrices in time O(nw) time, where w < 3. One example is Straßen's algorithm
that takes time O(nlog2(7)), i.e., O(n2.807...). The best known matrix multiplication
algorithm is due to Coppersmith and Winograd. Their algorithm has a running
time of O(n2.376). It is clear that for setting the value of all C[i][j]'s one must
perform at least n2 basic operations. It is still an open question whether O(n2)
running time suffices for matrix multiplication.
• Stack ADT operations
Look at the two implementations of the stack ADT detailed earlier. It is easy to
argue that each function (except print) performs only a constant number of
operations irrespective of the current size of the stack and so has a running time of
O(1). This is the reason why we planned to write seperate routines for the stack
and queue ADTs instead of using the routines for the ordered list ADT. Insertion
or deletion in the ordered list ADT may require O(n) time, where n is the current
size of the list.
• Partitioning in quick sort
This example illustrates the space complexity of a program (or function). We
concentrate only on the partitioning stage of the quick sort algorithm. The
following function takes the first element of the array as the pivot and returns the
last index of the smaller half of the array. The pivot is stored at this index.
int partition1 ( int A[] , int n )
{
int *L, *R, lIdx, rIdx, i, pivot;
L = (int *)malloc((n-1) * sizeof(int));
R = (int *)malloc((n-1) * sizeof(int));
pivot = A[0];
lIdx = rIdx = 0;
for (i=1; i<n; ++i) {
if (A[i] <= pivot) L[lIdx++] = A[i];
else R[rIdx++] = A[i];
}
for (i=0; i<lIdx; ++i) A[i] = L[i];
A[lIdx] = pivot;
for (i=0; i<rIdx; ++i) A[lIdx + 1 + i] = R[i];
free(L); free(R);
return lIdx;
}
Here we collect elements of A[] smaller than or equal to the pivot in the array L
and those that are larger than the pivot in the array R. We allocate memory for
these additional arrays. Since the sizes of L and R are not known a priori, we have
to prepare for the maximum possible size (n-1) for both. In addition, we use a
constant number (six) of variables. The total additional space requirement for this
function is therefore
2(n-1) + 6 = 2n + 4,
which is O(n).
Let us plan to reduce this space requirement. A possible first approach is to store
L and R in a single array LR of size n-1. Though each of L and R may be
individually as big as having a size of n-1, the total size of these two arrays must
be n-1. We store elements of L from the beginning and those of R from the end of
LR. The following code snippet incorporates this strategy:
int partition2 ( int A[] , int n )
{
int *LR, lIdx, rIdx, i, pivot;
LR = (int *)malloc((n-1) * sizeof(int));
pivot = A[0];
lIdx = 0; rIdx = n-1;
for (i=1; i<n; ++i) {
if (A[i] <= pivot) LR[lIdx++] = A[i];
else LR[rIdx--] = A[i];
}
for (i=0; i<lIdx; ++i) A[i] = LR[i];
A[lIdx] = pivot;
for (i=rIdx+1; i<n; ++i) A[i] = LR[i];
free(LR);
return lIdx;
}
The total amount of extra memory used by this function is
(n-1) + 5 = n + 4,
which, though about half of the space requirement for partition1, is still O(n).
We want to reduce the space complexity further. Using one or more additional
arrays will always incur O(n) space overhead. So we would avoid using any such
extra array, but partition A in A itself. This is called in-place partitioning. The
function partition3 below implements in-place partitioning. It works as follows.
It maintains the loop invariant that at all time the array A is maintained as a
concatenation LUR of three regions. The leftmost region L contains elements
smaller than or equal the pivot. The rightmost region R contains elements bigger
than the pivot. The intermediate region U consists of yet unprocessed elements.
Initially, U is the entire array A (or A without the first element which is taken to be
the pivot), and finally U should be empty. The region U is delimited by two indices
lIdx and rIdx indicating respectively the first and last indices of U. During each
iteration, the element at lIdx is compared with the pivot, and depending on the
comparison result this element is made part of L or R.
int partition3 ( int A[] , int n )
{
int lIdx, rIdx, pivot, t;
pivot = A[0];
lIdx = 1; rIdx = n-1;
while (lIdx <= rIdx) {
if (A[lIdx] <= pivot) {
/* The region L grows */
++lIdx;
} else {
/* Exchange A[lIdx] with the element at the U-R
boundary. */
t = A[lIdx];
A[lIdx] = A[rIdx];
A[rIdx] = t;
/* The region R grows */
--rIdx;
}
}
/* Place the pivot A[0] in the correct place by exchanging
it
with the last element of L */
A[0] = A[rIdx];
A[rIdx] = pivot;
return rIdx;
}
The function partition3 uses only four extra variables and so its space
complexity is O(1). That is a solid improvement over the earlier versions.
It is easy to check that the time complexity of each of these three partition
routines is O(n).
Worst-case versus average complexity
Our basic aim is to provide complexity figures (perhaps in the O notation) in terms of the
input size, and not as a function of any particular input. So far we have counted the
maximum possible number of basic operations that need be executed by a program or
function. As an example, consider the linear search algorithm. If the element x happens to
be the first element in the array, the function linSearch returns after performing only
few operations. The farther x can be located down the array, the bigger is the number of
operations. Maximum possible effort is required, when x is not at all present in the array.
We argued that this maximum value is O(n). We call this the worst-case complexity of
linear search.
There are situations where the worst-case complexity is not a good picture of the practical
situation. On an average, a program may perform much better than what it does in the
worst case. Average complexity refers to the complexity (time or space) of a program (or
function) that pertains to a random input. It turns out that average complexities for some
programs are markedly better than their worst-case complexities. There are even
examples where the worst-case complexity is exponential, whereas the average
complexity is a (low-degree) polynomial. Such an algorithm may take a huge amount of
time in certain esoteric situations, but for most inputs we expect the program to terminate
soon.
We provide a concrete example now: the quick sort algorithm. By partition we mean a
partition function for an array of n integers with respect to the first element of the array as
the pivot. One may use any one of the three implementations discussed above.
void quickSort ( int A[] , int n )
{
int i;
if (n <= 1) return;
i = partition(A,n); /* Partition with respect to A[0] */
quickSort(A,i); /* Recursively sort the left half
excluding the pivot */
quickSort(&A[i+1],n-i-1); /* Recursively sort the right half */
}
Let T(n) denote the running time of quickSort for an array of n integers. The running
time of the partition function is O(n). It then follows that:
T(n) <= T(i) + T(n-i-1) + cn + d
for some constants c and d and for some i depending on the input array A. The presence
of i on the right side makes the analysis of the running time somewhat difficult. We
cannot treat i as a constant for all recursive invocations. Still, some general assumptions
lead to easily derivable closed-form formulas for T(n).
An algorithm like quick sort (or merge sort) is called a divide-and-conquer algorithm.
The idea is to break the input into two or more parts, recursively solve the problem on
each part and subsequently combine the solutions for the different parts. For the quick
sort algorithm the first step (breaking the array into two subarrays) is the partition
problem, whereas the combining stage after the return of the recursive calls involves
doing nothing. For the merge sort, on the other hand, breaking the array is trivial -- just
break it in two nearly equal halves. Combining the solutions involves the non-trivial
merging process.
It follows intuitively that the smaller the size of each subproblem is, the easier it is to
solve each subproblem. For any superlinear function f(n) the sum
f(k) + f(n-k-1) + g(n)
(with g(n) a linear function) is large when the breaking of n into k,n-k-1 is very skew,
i.e., when one of the parts is very small and the other nearly equal to n. For example, take
f(n) = n2. Consider the function of a real variable x:
y = x2 + (n-x-1)2 + g(n)
Differentiation shows that the minimum value of y is attained at x = n/2 approximately.
The value of y increases as we move more and more away from this point in either
direction.
So T(n) is maximized when i = 0 or n-1 in all recursive calls, for example, when the
input array is already sorted either in the increasing or in the decreasing order. This
situation yields the worst-case complexity of quick sort:
T(n) <= T(n-1) + T(0) + cn + d
= T(n-1) + cn + d + 1
<= (T(n-2) + c(n-1) + d + 1) + cn + d + 1
= T(n-2) + c[n + (n-1)] + 2d + 2
<= T(n-3) + c[n + (n-1) + (n-2)] + 3d + 3
<= ...
<= T(0) + c[n + (n-1) + (n-2) + ... + 1] + nd + n
= cn(n-1)/2 + nd + n + 1,
which is O(n2), i.e., the worst-case time complexity of quick sort is quadratic.
But what about its average complexity? Or a better question is how to characterize an
average case here. The basic idea of partitioning is to choose a pivot and subsequently
break the array in two halves, the lesser mortals stay on one side, the greater mortals on
the other. A randomly chosen pivot is expected to be somewhere near the middle of the
eventual sorted sequence. If the input array A is assumed to be random, its first element
A[0] is expected to be at a random location in the sorted sequence. If we assume that all
the possible locations are equally likely, it is easy to check that the expected location of
the pivot is near the middle of the sorted sequence. Thus the average case behavior of
quick sort corresponds to
i = n-i-1 = n/2 approximately.
We than have:
T(n) <= 2T(n/2) + cn + d.
For simplicity let us assume that n is a power of 2, i.e., n = 2t for some positive integer
t. But then
T(n) = T(2t)
<= 2T(2t-1) + c2t + d
<= 2(2T(2t-2) + c2t-1 + d) + c2t + d
= 22T(2t-2) + c(2t+2t) + (2+1)d
<= 23T(2t-3) + c(2t+2t+2t) + (22+2+1)d
<= ...
<= 2tT(20) + ct2t + (2t-1+2t-2+...+2+1)d
= 2t + ct2t + (2t-1)d
= cnlog2n + n(d+1) - d.
The first term in the last expression dominates over the other terms and consequently the
average complexity of quick sort is O(nlog n).
Recall that bubble sort has a time complexity of O(n2). The situation does not improve
even if we assume an average scenario, since we anyway have to make O(n2)
comparisons in the nested loop. Insertion and selection sorts attain the same complexity
figure. With quick sort, the worst-case complexity is equally poor. But in practice a
random array tends to follow the average behavior more closely than the worst-case
behavior. That is reasonable improvement over quadratic time. The quick sort algorithm
turns out to be one of the practically fastest general-purpose comparison-based sorting
algorithm.
We will soon see that even the worst-case complexity of merge sort is O(nlog n). It is an
interesting theoretical result that a comparison-based sorting algorithm cannot run in time
faster than O(nlog n). Both quick sort and merge sort achieve this lower bound, the first
on an average, the second always. Historically, this realization provided a massive
impetus to promote and exploit recursion. Tony Hoare invented quick sort and
popularized recursion. We cannot think of a modern compiler without this facility.
Also, do you see the significance of the coinage divide-and-conquer?
We illustrated above that recursion made the poly-time Fibonacci routine exponentially
slower. That's the darker side of recursion. Quick sort and merge sort highlight the
brighter side. When it is your time to make a decision to accept or avoid recursion, what
will you do? Analyze the complexity and then decide.
How to compute the complexity of a program?
The final question is then how to derive the complexity of a program. So far you have
seen many examples. But what is a standard procedure for deriving those divine functions
next to the big-Oh? Frankly speaking, there is none. (This is similar to the situation that
there is no general procedure for integrating a function.) However, some common
patterns can be identified and prescription solutions can be made available for those
patterns. (For integration too, we have method of substitution, integration by parts, and
some such standard rules. They work fine only in presence of definite patterns.) The
theory is deep and involved and well beyond the scope of this introductory course. We
will again take help of examples to illustrate the salient points.
First consider a non-recursive function. The function is a simple top-to-bottom set of
instructions with loops embedded at some places in the sequence. One has to carefully
study the behavior of the loops and add up the total overhead associated with each loop.
The final complexity of the function is the sum of the complexities of each individual
instruction (including loops). The counting process is not always straighforward. There is
a deadly branch of mathematics, called combinatorics, that deals with counting
principles.
We have already deduced the time complexity of several non-recursive functions. Let us
now focus our attention to recursive functions. As we have done in connection with
quickSort, we write the running time of an invocation of a recursive function by T(n),
where n denotes the size of the input. If n is of a particular form (for example, if n has a
small value), then no recursive calls are made. Some fixed computation is done instead
and the result is returned. In this case the techniques for non-recursive functions need be
employed.
Finally, assume that the function makes recursive calls on inputs of sizes n1,n2,...,nk
for some k>=1. Typically each ni is smaller than n. These calls take respective times
T(n1),T(n2),...,T(nk). We add these times. Furthermore, we compute the time taken
by the function without the recursive calls. Let us denote this time by g(n). We then
have:
T(n) = T(n1) + T(n2) + ... + T(nk) + g(n).
Such an equation is called a recurrence relation. There are tools by which we can solve
recurrence relations of some particular types. This is again part of the deadly
combinatorics. We will not go to the details, but only mention that a recurrence relation
for T(n) together with a set of initial conditions (e.g. T(n) for some small values of n)
may determine a closed-form formula for T(n) which can be expressed by the Big O
notation. It is often not necessary to compute an exact formula for T(n). Proving a lower
and an upper bound may help us determine the order of T(n). Recall how we have
analyzed the complexity of the recursive Fibonacci function.
We end this section with two other examples of complexity analysis of recursive
functions.
Examples
• Computing determinants
The following function computes the determinant of an n x n matrix using the
expand-at-the-first-row method. It recursively computes n determinants of (n-
1) x (n-1) sub-matrices and then does some simple manipulation of these
determinant values.
int determinant ( int A[SIZE][SIZE] , int n )
{
int B[SIZE][SIZE], i, j, k, l, s;
if (n == 1) return A[0][0];
s = 0;
for (j=0; j<n; ++j) {
for (i=1; i<n; ++i) {
for (l=k=0; k<n; ++k) if (k != j) B[i-1][l++] =
A[i][k];
}
if (j % 2 == 0) s += A[0][j] * determinant(B,n-1);
else s -= A[0][j] * determinant(B,n-1);
}
}
I claim that this algorithm is an extremely poor choice for computing
determinants. If T(n) denotes the running of the above function, we clearly have:
T(1) = 1, and
T(n) >= n T(n-1) for n >= 2.
Multiple substitution of the second inequality then implies that:
T(n) >= n T(n-1)
>= n(n-1) T(n-2)
>= n(n-1)(n-2) T(n-3)
...
>= n(n-1)(n-2)...2 T(1)
= n!
How big is n! (factorial n)? Since i >= 2 for i = 2,3,...,n, it follows that
n! >= 2n-1. Thus the running-time of the above function is at least exponential.
Polynomial-time algorithms exist for computing determinants. One may use
elementary row operations in order to reduce the given matrix to a triangular
matrix having the same determinant. For a triangular matrix, the determinant is
the product of the elements on the main diagonal. We urge the students to exploit
this idea in order to design an O(n3) algorithm for computing determinants.
• Merge sort
The merge sort algorithm on an array of size n is depicted below:
void mergeSort ( int A[] , int n )
{
if (n <= 1) return;
mergeSort(A,n/2);
mergeSort(&A[n/2],n-(n/2));
merge(A,0,n/2-1,n/2,n-1);
}
For simplicity, assume that n = 2t for some t. The merge step on two arrays of
size n/2 can be easily seen to be doable in O(n) time. It then follows that:
T(1) = 1, and
T(n) <= 2 T(n/2) + cn + d
for some constants c and d. As in the average case of quick sort, one can deduce
the running time of merge sort to be O(nlog n).
Course home
CS13002 Programming and Data
Structures Spring
semester
Exercise set I
Note: Students are encouraged to solve as many problems from this set as possible. Some
of these will be solved during the lectures, if time permits. We have made attempts to
classify the problems based on the difficulty level of solving them. An unmarked exercise
is of low to moderate difficulty. Harder problems are marked by H, H2 and H
3 meaning
"just hard", "quite hard" and "very hard" respectively. Exercises marked by M have
mathematical flavor (as opposed to computational). One requires elementary knowledge
of number theory or algebra or geometry or combinatorics in order to solve these
mathematical exercises.
1. Assume that the CPU of a particular computer has three general-purpose registers
A,B,C. Assume also that m,n,t are integer values stored in the machine's memory.
Write assembly instructions for performing the following assignments. Use an
assembly language similar to that discussed in the examples given in the notes.
Assume that all operations are integer operations.
a. m = m + n - 1;
b. t = (m+5)*(n+5);
c. n = (m+5)/(n+5)+(n+5)/(m+5);
2. [M] Let n be a non-negative integer less than 2t. Let (at-1at-2...a1a0)2 be the t-bit
binary expansion of n obtained by the repeated divide-by-2 procedure described
in the notes. Prove that: 3. n = at-12
t-1 + at-22t-2 + ... + a12
1 + a0.
4. Write the 8-bit 2's complement representations of the following integers:
a. 123
b. -123
c. -7
d. 63
5. Find the 32-bit floating point representation of the following real numbers (under
the IEEE 754 format):
a. 123
b. -123
c. 0.1
d. 0.2
e. 0.25
f. -543.21
6. [H2M] Let x be a proper fraction, i.e., a real number in the range 0<=x<1. Prove
that x has a terminating binary expansion if and only if it is of the form a/2k for
some integers a,k with 0<=a<2k.
7. Let x,y,z be unsigned integers. Find the values of x,y,z after the following
statements are executed. 8. x = 5;
9. z = 12;
10. x *= x;
11. x += z * z;
12. y = x << 1;
13. z = y % z;
14. Assume that m and n are (signed) integer variables and that x and y are floating
point variables. Write logical conditions that evaluate to "true" if and only if:
a. x+y is an integer.
b. m lies strictly between x and y.
c. m equals the integer part of x.
d. x is positive with integer part at least 3 and with fractional part less than
0.3.
e. m and n have the same parity (i.e., are both odd or both even).
f. m is a perfect square.
15. Write a program to solve the following problems:
a. Show that -29 and 31 are roots of the polynomial x3 + x2 - 905x -
2697. What is its third root?
b. Show that -2931 is a root of the polynomial x3 + 2871x2 - 174961x +
2634969.
c. The three roots of the polynomial x3 + x2 - 74034x + 5294016 are
integers. Find them.
d. The three roots of the polynomial x3 + x2 - 28033x - 1815937 are
again integers. Find them.
16. Read five positive real numbers a, b, c, d and e from the user and compute their
arithmetic mean, geometric mean, harmonic mean and standard deviation.
17. Input four integers a, b, c and d with b and d positive.
a. Output the rational numbers (not their float equivalents) (a/b)+(c/d), (a/b)-
(c/d) and (a/b)*(c/d).
b. Output the rational numbers (a/b)+(c/d), (a/b)-(c/d) and (a/b)*(c/d) in
lowest terms, that is, in the form m/n with n>0 and gcd(m,n)=1.
18. Input four integers a, b, c and d with a or b (or both) non-zero and with c or d (or
both) non-zero.
a. Output the complex numbers (a+ib)/(c+id) and (c+id)/(a+ib) in the form
(r/s)+i(u/v) with r,s,u,v integers and s,v>0.
b. Output the complex numbers (a+ib)/(c+id) and (c+id)/(a+ib) in the form
(r/s)+i(u/v) with r/s and u/v in lowest terms, that is, with s,v>0 and with
gcd(r,s)=gcd(u,v)=1.
19. Let m and n be 32-bit unsigned integers. Use bit operations to assign to m the
following functions of n:
a. 1 if n is odd, 0 if n is even.
b. 1 if n is divisible by 4, 0 otherwise.
c. 2n (Assume that n<=31).
d. n rotated by k positions to the left for some integer k>=0.
e. n rotated by k positions to the right for some integer k>=0.
20. Write a program that does the following: Scan six real numbers a,b,c,d,e,f and
compute the point of intersection of the straight lines: 21. ax + by = c
22. dx + ey = f
Your program should specifically handle the case that the two given lines are
parallel.
23. Write a program to determine the roots of a quadratic equation. Figure out a way
to handle the case when the the roots are not real.
24. Write a program that scans a string and checks if it represents a valid date in the
format DD-MM-YYYY. (Example: 29-02-2005 is not a valid date, but 29-02-
2004 is valid.)
25. An ant is sitting at the left end of a rope of length 10 cm. At t=0 the ant starts
moving along the rope to reach the other end of the rope. The ant has a speed of
1 cm per second. After every second the rope stretches instantaneously and
uniformly (along its length) by 10 cm with the left end fixed at the point from
where the ant started its journey. Suppose that the ant's legs provide it sufficient
friction in order to withstand the stretching of the rope. Write a program to
demonstrate that the ant will be able to reach the right end of the rope. Your
program should also calculate how many seconds the ant would take to achieve
this goal. You may assume that the length of the ant is negligible (i.e., zero).
Note: The ant would reach the right end of the rope, even if its initial length and
stretching per second were 1 km (or even a billion kilometers) instead of 10 cm.
But for these dimensions the ant would take such an unbelievably large time that
your program will not give you the confirmation in your life-time. Moreover, you
will require more precision than what double can provide. Try to solve this
puzzle mathematically.
26. Randomly generate a sequence of integers between -5 and +99 and output the
maximum and minimum values generated so far. Exit, if a negative integer is
generated. You must not store the sequence generated (say using an array), but
update the maximum and minimum values on the fly, as soon as a new entry is
generated. A sample run is given below: 27. Iteration 1: new entry = 84, max = 84, min = 84
28. Iteration 2: new entry = 87, max = 87, min = 84
29. Iteration 3: new entry = 72, max = 87, min = 72
30. Iteration 4: new entry = 53, max = 87, min = 53
31. Iteration 5: new entry = 93, max = 93, min = 53
32. ...
33. It is known that the harmonic number Hn converges to k + ln n as n tends to
infinity. Here ln is the natural logarithm and k is a constant known as Euler's
constant. In this exercise you are asked to compute an approximate value for
Euler's constant. Generate the values of Hn and ln n successively for
n=1,2,3,..., and compute the difference kn = Hn - ln n. Stop when kn-kn-1 is
less than a specific error bound (like 10-8).
Note: It is not known whether Euler's constant is rational or not. It has, however,
been shown that if Euler's constant is rational, its denominator must have more
than 10,000 decimal digits.
34. Write a program that, given a positive integer n, determines the integer t with the
property that 2t-1<=n<2t. This integer t is called the bit-length of n.
35. A Pythagorean triple is a triple (a,b,c) of positive integers with the property that
a2+b
2=c
2. Write a program that scans a positive integer value k and outputs all
Pythagorean triples (a,b,c) with 0<a<=b<c<=k.
36. Consider the function 37. f(a,b) = (a2+b2)/(ab-1)
of two positive integers a,b with ab>1.
a. Write a program that scans a positive integer k and prints the three values
a,b,f(a,b) if and only if 0<a<=b<=k and f(a,b) is an integer.
b. [H3M] Do you see a surprising fact about these f(a,b) values? Can you
prove your hunch?
38. Given a number in decimal write a program to print the reverse of the number.
For example, the reverse of 3481 is 1843.
39. Write a program that does the following: Read a decimal integer and print the
ternary (base 3) representation of the integer.
40. Write a program that does the following: Read a string of 0's and 1's and print the
decimal equivalent of the string treated as an integer in the binary representation.
41. [H] Write a program that does the following: Read a string of 0's, 1's and 2's and
print the decimal equivalent of the string treated as an integer in the ternary
representation.
42. Write a program that, given a positive number x (not necessarily integral) and an
integer k, computes the kth root of x using the bisection method. Supply an
accuracy for your answer.
43. Generate a random sequence of birthdays and store the birthdays in an array. As
soon as a match is found, report that. Also report how many birthdays were
generated to get the match.
Note: It is surprising to see that you usually require a very small number of
people (around 25) in order to have a match in their birthdays. However counter-
intuitive it might sound, it is a mathematical truth, commonly known as the
birthday paradox. In short it says that if you draw (with replacement) about
sqrt(n) samples from a pool of n objects, there is about 50/50 chance that you get
a repetition. If you draw 2 sqrt(n) samples, you can be almost certain that there
will be at least one repetition.
44. Write a program that, given an array A[] of integers, finds out the largest and
second largest elements of the array.
45. Write a program that, given an array A[] of signed integers, finds out a
subsequence i,i+1,...,j such that the sum 46. A[i] + A[i+1] + ... + A[j]
is maximum over all such subsequences. Note that the problem is trivial if all
numbers are positive -- your algorithm should work when the numbers may have
different signs.
47. [H2] Can you write a program that solves the problem of the last exercise using
roughly n operations?
48. Read an English sentence from the terminal. Count and print the number of
occurrences of the alphabetic letters (a through z) in it. Also print the total number
of distinct alphabetic letters in the sentence. Make no distinction between upper
and lower case letters, i.e., 'a' is treated the same as 'A', 'b' the same as 'B' and so
on. Neglect non-alphabetic characters (digits, spaces, punctuation symbols etc.).
49. Input two strings a and b from the user and check if b is a substring of a. If b is a
substring of a, then your program should also print the leftmost position of the
leftmost match of b in a.
50. Write a program that scans a positive integer and checks if the integer is a perfect
number (i.e., a number which is equal to the sum of all its proper integral divisors,
e.g., 6 = 1+2+3).
51. Write a program that reads a positive integer n and lists all primes between 1 and
n. Use the sieve of Eratosthenes described below:
Use an array of n cells indexed 1 through n. Since C starts indexing from 0, one
may, for the ease of referencing, use an array of n+1 cells (rather than n). Initially
all the array cells are unmarked. During the process one marks the cells with
composite indices. An unmarked cell holds the value 0, a marked cell holds 1.
Henceforth, let us abbreviate "marking the cell at index i" as "marking i".
Any positive integral multiple of a positive integer k, other than k itself, is called
a proper multiple of k. Starting with k=2, mark all proper multiples of 2 between
1 and n. Then look at the smallest integer >2 that has not been marked. This is
k=3 and must be a prime. Mark all the proper multiples of 3 and then look at the
next unmarked integer -- this is k=5. Then mark the proper multiples of 5 and so
on. The process need continue as long as k<=sqrt(n), since every composite
integer m, 1<m<=n, must have a prime divisor <=sqrt(n).
After the loop described in the last paragraph terminates, report the indices of the
unmarked cells in your array. These are precisely all the primes in the range
1,2,...,n.
Now adjust the bound n in order to detect the millionth prime.
52. [HH] Repeat the above problem where a cell is marked at most once. In the
previous description, cell 6 will will get marked when we consider 2 as well as 3
etc.
53. [HM] Write a program that, given two integers a,b with 0<a<b, finds integers
n1,n2,...,nk with the properties that: 54. n1 < n2 < ... < nk and
55. a/b = 1/n1 + 1/n2 + ... + 1/nk.
(Hint: You may use the following idea. If a/b is already of the form 1/n, we are
done. Otherwise, find the integer n such that 1/n<a/b<1/(n-1). Print n, replace a/b
by (a/b)-(1/n) and repeat. Prove that this gives a strictly increasing sequence of
printed values (n) and that the process terminates after finitely many steps.)
56. Write a program that, given a set of n points in the plane (specified by their x and
y coordinates), determines the smallest circle that encloses all these points. (Hint:
The smallest circle must either pass through three given points or have two given
points at the opposite ends of a diameter.)
57. In this exercise you are asked to compute approximate values of pi.
a. Write the infinite series for 1/(1+x2).
b. Integrate (between 0 and x) both 1/(1+x2) and the infinite series for it, put
the value x = 1/sqrt(3) and write pi as an infinite series.
c. Truncate the series after n terms and evaluate the truncated series to get an
approximate value of pi. Use the values n=10i for i=1,2,...,6.
d. Write the infinite series for 1/sqrt(1-x2).
e. Integrate (between 0 and x) both 1/sqrt(1-x2) and the infinite series for it,
put the value x = 1/2 and write pi as an infinite series.
f. Truncate the series after n terms and evaluate the truncated series to get an
approximate value of pi. Use the values n=10i for i=1,2,...,6.
58. [H] Write a program to determine the smallest positive integer n with the
following property. Let 59. n = akak-1...a1a0
be the decimal representation of n with ak>0. Look at the integer:
n' = a0akak-1...a2a1
(the cyclic right shift of n). The desired property of n is that n' must be a proper
integral multiple of n.
60. [H] Write a program to find the smallest positive integer n with the property that
the decimal expansion of 2n starts with the four digits 2005, i.e., 2
n = 2005...
(Hint: Take log.)
Course home
CS13002 Programming and Data
Structures Spring
semester
Exercise set II
Note: Students are encouraged to solve as many problems from this set as possible. Some
of these will be solved during the lectures, if time permits. We have made attempts to
classify the problems based on the difficulty level of solving them. An unmarked exercise
is of low to moderate difficulty. Harder problems are marked by H, H2 and H
3 meaning
"just hard", "quite hard" and "very hard" respectively. Exercises marked by M have
mathematical flavor (as opposed to computational). One requires elementary knowledge
of number theory or algebra or geometry or combinatorics in order to solve these
mathematical exercises.
1. Write functions to compute the following:
a. The area of a circle whose diameter is supplied as an argument.
b. The volume of a 3-dimensional sphere whose surface area is given as an
argument.
c. The area of an ellipse for which the lengths of the major and minor axes
are given as arguments.
d. Given the coordinates of three distinct points in the x-y plane, the radius of
the circle circumscribing the three points. Your function should return -1
if the three given points are collinear.
e. Given a positive integer n, the sum of squares of all (positive) proper
divisors of n.
f. Given integers n>=0 and b>1, the expansion of n in base b. (Example:
(987654321)_10 = (4,38,92,23,114)_123.)
g. Given n and an array of n positive floating point numbers, the geometric
mean of the elements of the array.
2. Write functions to perform the following tasks:
o Check if a positive integer (provided as parameter) is prime.
o Check if a positive integer (provided as parameter) is composite.
o Return the sum S7(n) of the 7-ary digits of a positive integer n (supplied
as parameter).
Use the above functions to find out the smallest positive integer i for which
S7(pi) is composite, where pi is the i-th prime. Also print the prime pi.
Note: 1 is neither prime nor composite. The sequence of primes is denoted by p1=2, p2=3, p3=5, p4=7, p5=11, ...
As an illustrative example for this exercise consider the 31-st prime p31 = 127 that
expands in base 7 as
127 = 2 x 72 + 4 x 7 + 1,
i.e., the 7-ary expansion of 127 is 241 and therefore
S7(127) = 2 + 4 + 1 = 7,
which is prime.
3. Write a function that, given two points (x1,y1) and (x2,y2) in the plane, returns
the distance between the points.
Write another function that computes the radius of the circle
x2 + y2 + ax + by + c
defined by the triple (a,b,c). Note that for some values of (a,b,c) this radius is not
defined. In that case your function should return some negative value.
Input two triples (a1,b1,c1) and (a2,b2,c2) so as to define two circles. Use the
above two functions to determine which of the following cases occurs:
o One or both of the circles is/are undefined.
o The two circles touch (i.e., meet at exactly one point).
o The two circles intersect (at two points).
o The two circles do not intersect at all.
4. [M] Use the principle of mathematical induction to prove the following
assertions:
. x2n+1
+y2n+1
is divisible by x+y for all n in N0.
a. 1/sqrt(1) + 1/sqrt(2) + ... + 1/sqrt(n) > 2(sqrt(n+1) - 1) for all n in N.
b. F12 + F2
2 + ... + Fn
2 = FnFn+1 for all n in N0, where Fn denotes the n-the
Fibonacci number.
c. H1 + H2 + ... + Hn = (n+1)Hn - n for all n in N0, where Hn denotes the n-
th harmonic number.
5. [M] Find the flaw in the following proof:
Theorem: All horses are of the same color.
Proof Let there be n horses. We proceed by induction on n. If n=1, there is
nothing to prove. So assume that n>1 and that the theorem holds for any group of
n-1 horses. From the given n horses discard one, say the first one. Then all the
remaining n-1 horses are of the same color by the induction hypothesis. Now put
the first horse back and discard another, say the last one. Then the first n-1 horses
have the same color again by the induction hypothesis. So all the n horses must
have the same color as the ones that were not discarded either time. QED
6. Find a loop invariant for each of the following loops: 7. a. int n, x, y, t;
8. 9. n = 0; 10. x = 1 + rand() % 9; 11. y = 1 + rand() % 9; 12. while (n < 10) { 13. t = 1 + rand() % 9; 14. x *= t; 15. y /= t; 16. ++n; 17. } 18. 19. b. #define NITER 100 20. 21. double x, s, t; 22. int i; 23. 24. i = 0; s = t = 1; 25. do { 26. ++i; 27. t /= (double)i; 28. s += t; 29. } while (i < NITER); 30. 31. c. #define NITER 10 32. int i; 33. double A[NITER] = {1.0/2, 1.0/3, 1.0/5, 1.0/7, 1.0/11, 34. 1.0/13, 1.0/17, 1.0/19, 1.0/23, 1.0/29}; 35. 36. for (i=1; i<NITER; ++i) A[i] += A[i-1];
37. [HM] Consider the following puzzle given in pseudocode: 38. Let n be an odd positive integer. 39. Initialize C to the collection of integers 1,2,...,2n. 40. while (C contains two or more elements) { 41. Randomly choose two elements from the collection, call
them a and b. 42. Remove these elements from C. 43. Add the absolute difference |a-b| to C. 44. }
For every iteration of the loop, two elements are removed and one element is
added, so the size of the collection reduces by 1. After 2n-1 iterations the
collection contains a single integer, call it t, and the loop terminates. Prove that t
is odd. (Hint: Try to locate a delicate loop invariant.)
45. Determine what each of the following foomatic functions computes: 46. a. unsigned int foo1 ( unsigned int n ) 47. { 48. unsigned int t = 0; 49. 50. while (n > 0) { 51. if (n % 2 == 1) ++t; 52. n = n / 2; 53. } 54. return t; 55. }
56. 57. b. unsigned int foo2 ( unsigned int n ) 58. { 59. unsigned int t = 0; 60. 61. while (n > 0) { 62. if (n & 1) ++t; 63. n >>= 1; 64. } 65. return t; 66. } 67. 68. c. double foo3 ( double a , unsigned int n ) 69. { 70. double s, t; 71. 72. s = 0; 73. t = 1; 74. while (n > 0) { 75. s += t; 76. t *= a; 77. --n; 78. } 79. return s; 80. } 81. 82. d. double foo4 ( float A[] , int n ) 83. { 84. float s, t; 85. 86. s = t = 0; 87. for (i=0; i<n; ++i) { 88. s += A[i]; 89. t += A[i] * A[i]; 90. } 91. return (t/n)-(s/n)*(s/n); 92. } 93. 94. e. int foo5 ( unsigned int n ) 95. { 96. if (n == 0) return 0; 97. return 3*n*(n-1) + foo5(n-1) + 1; 98. } 99. 100. f. int foo6 ( char A[] , unsigned int n ) 101. { 102. int t; 103. 104. if (n == 0) return 0; 105. t = foo6(A,n-1); 106. if ( ((A[n-1]>='a') && (A[n-1]<='z')) || 107. ((A[n-1]>='A') && (A[n-1]<='Z')) || 108. ((A[n-1]>='0') && (A[n-1]<='9')) ) 109. ++t; 110. return t; 111. } 112.
113. g. int foo7 ( unsigned int a , unsigned int b ) 114. { 115. if ((a == 0) || (b == 0)) return 0; 116. return a * b / bar7(a,b); 117. } 118. 119. int bar7 ( unsigned int a , unsigned int b ) 120. { 121. if (b == 0) return a; 122. return bar7(b,a%b); 123. } 124. 125. h. int foo8 ( unsigned int n ) 126. [ 127. if (n == 0) return 0; 128. if (n & 1) return -1; 129. return 1 + bar8(n-1); 130. } 131. 132. int bar8 ( int n ) 133. { 134. if (!(n & 1)) return -2; 135. return 2 + foo8(n-1); 136. }
137. [HM] Prove that the following function correctly computes the number of
trailing 0's in the decimal representation of n! (factorial n). 138. int bar ( unsigned int n ) 139. { 140. int t = 0; 141. 142. while (n > 0) { 143. n /= 5; 144. t += n; 145. } 146. return t; 147. }
148. For k in N we have 149. a2k = (ak)2, and 150. a2k+1 = (ak)2 x a.
Use this observation to write a recursive function that, given a real number a and
a non-negative integer n, computes the power an.
151. Write a recursive function that computes the binomial coefficient C(n,r)
using the inductive definition: 152. C(n,r) = C(n-1,r) + C(n-1,r-1)
for suitable values of n and r. Supply appropriate boundary conditions.
153. Define a sequence Gn as:
Gn = 0 1
if n = 0, if n = 1,
2 Gn-1 + Gn-2 + Gn-3
if n = 2, if n >= 3.
. Write an iterative function for the computation of Gn for a given n.
a. Write a recursive function for the computation of Gn for a given n.
b. [H] Write an efficient recursive function for the computation of Gn for a
given n. Here efficiency means recursive invocation of the function for no
more than n times.
154. Consider the sequence of integers given by: 155. a1 = 1, 156. a2 = 1, 157. an = 6an-2 - an-1 for n >= 3.
. Write a recursive function to compute a20.
a. Write an iterative function to compute a20.
b. Suppose that a mathematician tells you that c. an = (2n+1 + (-3)n-1)/5 for all n>=1.
Use this formula to compute a20.
Compare the timings of these three approaches for computing a20. In order
to measure time, use the built-in function clock() defined in <time.h>.
158. Consider three sequences of integers defined recursively as follows: 159. a0 = 0 160. a1 = 1 161. an = a[n/3] - 2bn-2 + cn for n >= 2 162. 163. b0 = -1 164. b1 = 0 165. b2 = 1 166. bn = n - an-1 + cn-2 - bn-3 for n >= 3 167. 168. c0 = 1 169. cn = bn - 3c[n/2] + 5 for n >= 1
Here for a real number x the notation [x] stands for the largest integer less than or
equal to x. For example, [3]=3, [3.1416]=3, [0.1416]=0, [-1.1416]=-2, [-
3]=-3, [5/3]=1, [-5/3]=-2, etc. For this exercise you need consider x>=0 only,
in which case [x] can be viewed as the integral part of x.
. Write three mutually recursive functions for computing an, bn and cn.
a. Compute a25. Count the total number of times ai, bi and ci are computed for
i=0,...,25 (that is, the corresponding functions are called with argument
i) during the computation of a25.
b. Compute b25. Count the total number of times ai, bi and ci are computed
for i=0,...,25 during the computation of b25.
c. Compute c25. Count the total number of times ai, bi and ci are computed for
i=0,...,25 during the computation of c25.
d. Write an iterative version of the mutually recursive procedure. Maintain
three arrays a, b and c each of size 26. Use the boundary conditions
(values for a0, a1, b0 etc.) to initialize. Then use the recursive definition to
update the a, b and c values "simultaneously". In this method if some
value (say, ai) is once computed, it is stored in the appropriate array
location for all subsequent uses. This saves the time for recalculating the
same value again and again.
e. Compute the values a25, b25 and c25 using the iterative procedure.
170. [M] What is wrong in the following mutually recursive definition of three
sequences an, bn and cn? 171. a0 = 1. 172. an = an-1 + bn for n >= 1. 173. 174. b0 = 2. 175. bn = bn-1 + cn for n >= 1. 176. 177. c0 = -3. 178. cn = cn-1 + an for n >= 1.
179. Two frogs are sitting at the bottom of a flight of 10 steps and debating in
how many ways then can jump up the stairs. They can jump one, two or three
steps at once. For example, they can cover the 10 steps by jumping (3,3,3,1) or
(2,3,2,1,2) or other suitable combinations of steps. Their mathematics is not very
strong and they approach you for help in order to find out the total number of
possibilities they have to reach the top. Please provide them with a general
solution (not only for 10 but for general n steps) in the form of a C function. Note
that the order of the steps is important here, i.e., (3,3,3,1) is treated distinct from
(1,3,3,3) for example.
180. Suppose we want to compute the product a0 x a1 x ... x an of n+1
numbers. Since multiplication is associative, we can insert parentheses in any
order in order to completely specify the sequence of multiplications. For example,
for n=3 we can parenthesize a0 x a1 x a2 x a3 in the following five ways: 181. a0 x (a1 x (a2 x a3)) 182. a0 x ((a1 x a2) x a3) 183. (a0 x a1) x (a2 x a3) 184. (a0 x (a1 x a2)) x a3 185. ((a0 x a1) x a2) x a3
The number of ways in which n+1 numbers can be multiplied is denoted by Cn
and is called the n-th Catalan number.
. [HM] Show that Catalan numbers can be recursively defined as follows: a. C0 = 1, b. C1 = 1, and c. Cn = C0Cn-1 + C1Cn-2 + ... + Cn-2C1 + Cn-1C0 for n>=2.
(Hint: Classify a multiplication sequence based on the last multiplication.)
d. Write an iterative function to compute Cn for a given n. (Remark: The
computation of Cn requires all the previous values C0,C1,...,Cn-1. So you
are required to store Catalan numbers in an array.)
e. Write a recursive function to compute Cn.
f. [H] Write an efficient recursive function to compute Cn. Here efficiency
means that each Ci is to be computed only once in the entire sequence of
recursive calls.
186. [HM] In this exercise we work with permutations of 1,2,...,n.
. Write a recursive function that prints all permutations of 1,2,...,n with
each permutation printed only once.
a. [H2] Write an iterative function that prints all permutations of 1,2,...,n
with each permutation printed only once.
b. A permutation p = a1,a2,...,an of 1,2,...,n can be treated as a function c. p : {1,2,...,n} --> {1,2,...,n}
with p(i)=ai for all i=1,2,...,n. If p(b1)=b2, p(b2)=b3, ..., p(bk-1)=bk and
p(bk)=b1, we say that (b1,b2,...,bk) is a cycle of length k in p. A
permutation can be written as a collection of pairwise disjoint cycles. For
example, consider the permutation p of 1,2,...,10:
195. Stirling numbers s(n,k) of the first kind are non-negative integers
defined recursively as: 196. s(0,0) = 1, 197. s(n,0) = 0 for n > 0, 198. s(n,k) = 0 for k > n, 199. s(n,k) = (n-1)s(n-1,k) + s(n-1,k-1) for n > 0 and 0 < k <= n.
. Write a recursive function to compute s(n,k).
a. Write an iterative function to compute s(n,k). You should better maintain a
two-dimensional array and compute the values s(n,k) in a particular order
of the pair (n,k).
200. Stirling numbers S(n,k) of the second kind are non-negative integers
defined recursively as: 201. S(0,0) = 1, 202. S(n,0) = 0 for n > 0, 203. S(n,k) = 0 for k > n, 204. S(n,k) = k S(n-1,k) + S(n-1,k-1) for n > 0 and 0 < k <= n.
. Write a recursive function to compute S(n,k).
a. Write an iterative function to compute S(n,k).
205. A run in a permutation is a maximal monotonic increasing sequence of
adjacent elements in the permutation. For example, the runs in 206. 257183496
are
257, 18, 349, 6.
Every run (except the last) is followed by a descent (also called a fall). For
example, in the above permutation the descents are 71, 83 and 96. If a
permutation has exactly k+1 runs, then it has exactly k descents, and conversely.
Let us denote by the notation
<n,k>
the number of permutations of 1,...,n with exactly k descents. The numbers
<n,k> are called Eulerian numbers. All permutations of 1,2,3 and the runs in
each permutation are shown below. This list gives us the values of <3,k>.
Permutation Runs Number of runs Number of descents 123 123 1 0 132 13,2 2 1 213 2,13 2 1 231 23,1 2 1 312 3,12 2 1 321 3,2,1 3 2
<3,0> = 1 <3,1> = 4 <3,2> = 1
It is known that the Eulerian numbers satisfy the following recurrence relation:
<n,0> = 1. <n,k> = 0, if k >= n. <n,k> = (k+1)<n-1,k> + (n-k)<n-1,k-1>, otherwise.
. Write a recursive function to compute <n,k>.
a. Write an iterative function to compute <n,k>.
207. In this exercise you are asked to build a function library on matrix
arithmetic. Consider matrices (not necessarily square) with real entries.
. First chalk out a way to represent a matrix in a two-dimensional array.
You may restrict the dimension of a matrix to be less than some bound,
say 20.
a. Write functions to perform the following operations on matrices. All the
input and output matrices should be passed as arguments to the functions.
Each function is allowed to return nothing or an integer value. You should
also check that the dimensions of the input matrices are consistent for the
operation. Your functions should allow the provision for the output matrix
being the same as one of the input matrices.
� Initialization of a matrix to the zero matrix of a given dimension.
� Initialization of a matrix to the (square) identity matrix of a given
dimension.
� Addition of two matrices.
� Difference of two matrices.
� Multiplication of two matrices.
� Inverse of a (square) matrix.
� Rank of a matrix.
� Scanning of a matrix.
� Printing of a matrix.
208. Use your library of the previous exercise to solve a square system of linear
equations. If the system is underdefined or inconsistent, your program should
report failure.
209. [M] A square matrix A = (aij) is called symmetric if aij = aji for all
indices i,j. A is called skew-symmetric if aij = -aji for all indices i,j with
i != j. Write a function that, given a square matrix A, computes a symmetric
matrix B and a skew-symmetric matrix C satisfying A = B + C.
Course home
CS13002 Programming and Data
Structures Spring
semester
Exercise set III
Note: Students are encouraged to solve as many problems from this set as possible. Some
of these will be solved during the lectures, if time permits. We have made attempts to
classify the problems based on the difficulty level of solving them. An unmarked exercise
is of low to moderate difficulty. Harder problems are marked by H, H2 and H
3 meaning
"just hard", "quite hard" and "very hard" respectively. Exercises marked by M have
mathematical flavor (as opposed to computational). One requires elementary knowledge
of number theory or algebra or geometry or combinatorics in order to solve these
mathematical exercises.
1. Consider the data type complex discussed in the notes. Write a function that takes
an array of complex numbers as input and sorts the array with respect to the
absolute values of the elements of the array.
2. Write a function that calls the arithmetic routines on the complex data type in
order to compute the two complex square roots of a quadratic equation with
complex coefficients.
3. Define a structure to represent an (ordered) pair of integers. For two pairs (a,b)
and (c,d) we say (a,b) < (c,d) if and only if either a < c or a = c and b < d. Write a
function that sorts an array of integer pairs with respect to this ordering (called
lexicographic ordering).
4. A rational number is defined by a pair of integers (a,b) with b > 0 and is
interpreted to stand for a/b. A rational number a/b is said to be in the reduced
form if gcd(a,b)=1.
a. Define a structure to represent a rational number.
b. Write a function that returns the rational number 0/1. This function can be
used to initialize a rational number variable.
c. Write a function that, given a rational number, returns its reduced form.
d. Write a function that, upon input of two rational numbers, returns the sum
of the two input numbers in the reduced form.
e. Repeat the previous part for subtraction, multiplication and division of
rational numbers.
5. Consider the following subset of complex numbers: 6. Q(i) = { x + iy | x and y are rational numbers and i = sqrt(-
1) }.
a. Define a structure to represent an element of Q(i). Use the rational
structure of the previous exercise for this definition.
b. By invoking the arithmetic routines on rational numbers implemented in
the previous exercise, implement the arithmetic (addition, subtraction,
multiplication and inverse) in Q(i).
7. Define a structure representing a book and having the following fields: Name, list
of authors, publisher, year of publication and ISBN number. Write functions to do
the following tasks on an array of books:
a. Find all books published in a given year.
b. Find all books published between two given years.
c. Find all books from a given publisher.
d. Find all books from a given author.
e. Sort the books by their ISBN numbers.
f. Sort the books by their names.
g. Sort the books by their first authors.
8. Consider the following set of real numbers: 9. A = { a + b sqrt(2) | a,b are integers }.
a. Define a structure to represent an element of A.
b. Write a function that, upon input of two elements of A, returns the sum of
the input elements.
c. Repeat the last part for subtraction and multiplication of elements of A.
d. It is known that the element a + b sqrt(2) has an inverse in the set A if and
only if a2 - 2b
2 = 1 or -1. Write a function that, given an invertible element
of A, returns the inverse of the element. If the input to the function is not
invertible, the function should return 0 after prompting an error message.
10. Consider the following set of complex numbers: 11. B = { a + b sqrt(-2) | a,b are integers }.
a. Define a structure to represent an element of B.
b. Write a function that, upon input of two elements of B, returns the sum of
the input elements.
c. Repeat the last part for subtraction and multiplication of elements of B.
d. [M] It is known that the element a + b sqrt(-2) has an inverse in the set B
if and only if a2 + 2b
2 = 1. Prove that the only invertible elements of B are
1 and -1.
12. Consider the following set of real numbers: 13. C = { a + by | a,b are integers }.
where y = [1 + sqrt(5)] / 2.
a. Define a structure to represent an element of C.
b. Write a function that, upon input of two elements of C, returns the sum of
the input elements.
c. Repeat the last part for subtraction and multiplication of elements of C.
d. It is known that the element a + by has an inverse in the set C if and only
if a2 + ab - b
2 = 1 or -1. Write a function that, given an invertible element
of C, returns the inverse of the element. If the input to the function is not
invertible, the function should return 0 after prompting an error message.
14. [HM] Consider the following set of real numbers: 15. D = { a + bw + cw2 | a,b,c are integers }.
where w is the real cube root of 2.
a. Define a structure to represent an element of D.
b. Write a function that, upon input of two elements of D, returns the sum of
the input elements.
c. Repeat the last part for subtraction and multiplication of elements of D.
d. [H2M] Define the norm of an element t = a + bw + cw
2 of D as
e. N(t) = (a + bw + cw2)(a + bw' + cw'2)(a + bw'' + cw''2),
where w',w'' are the two complex cube roots of 2. It is known that t is
invertible in D if and only if N(t) is 1 or -1. Write a function that, upon
input of an element t of D, determines whether t is invertible in D.
Note: In modern algebra, the sets A,B,C,D of the above exercises are examples of
number rings. These rings constitute some central objects of study in algebraic
number theory.
16. A circle in the X-Y plane is specified by three real numbers a,b,c. The real
numbers may be interpreted in two possible ways. The first possibility is that (a,b)
represents the center and c the radius of the circle. In the second representation,
we refer to the equation of the circle as: 17. X2 + Y2 + aX + bY + c = 0.
So a structure holding three double variables together with a flag indicating the
particular interpretation suffices to store a circle.
a. Write a function that converts a circle structure from the first to the second
representation.
b. Write a function that converts a circle structure from the second to the first
representation.
c. Write a function that, upon input a circle and two real numbers x,y, checks
whether the point (x,y) lies inside, on or outside the circle. Note that the
input circle may be of any of the two representations.
d. Write a function that, upon input two circles each with any representation,
determines whether the circles touch, intersect or do not intersect.
e. Write a function that, upon input a circle in any representation, returns the
side of a square that has the same area as the input circle.
18. A rectangle in the X-Y plane can be specified by eight real numbers representing
the coordinates of its four corners. Define a structure to represent a rectangle
using eight double variables. Notice that here we do not assume the sides of a
rectangle to be necessarily parallel to the X and Y axes. Notice also that by a
rectangle we mean only the boundary (not including the region inside).
a. Write a function that, upon input a structure of the above kind, determines
whether the structure represents a valid rectangle.
b. Write a function that, upon input a valid rectangle, determines the area of
the rectangle.
c. [HM] Write a function that, upon input a valid rectangle and two real
numbers x,y, determines whether the point (x,y) lies inside, on or outside
the rectangle.
d. [H2M] Write a function that, upon input two valid rectangles, determines
whether the two rectangles touch, intersect or do not intersect.
19. In a doubly linked list, each node is given two pointers, the first pointing to the
next element in the list and the second to the previous element in the list. The next
pointer of the last node and the previous pointer of the first node should be
NULL.
a. Draw a doubly linked list on four nodes.
b. Define a structure with self-referencing pointers to represent a node in a
doubly linked list.
20. Use a sequence of memory allocation calls in order to create a linked list of one
hundred complex numbers, where for each k=1,2,...,100 the k-th number in the
list is k2 + i(-1)kk2.
21. Create a doubly linked list of the 100 complex numbers of the previous exercise.
22. In a ternary tree each node has three children (each possibly empty).
a. Draw a ternary tree having the following nodes: b. Node Children c. a b,c,d d. b e,-,f e. c -,-,g f. d -,-,- g. e -,h,- h. f -,-,- i. g -,-,- j. h -,-,-
Here - (dash) denotes an empty child.
k. Define a structure data type with self-referencing pointers to represent a
node in a ternary tree.
23. Use a sequence of memory allocation calls to create the ternary tree of the last
exercise.
24. Consider the following type definition: 25. typedef char *compactString;
The idea is to dynamically store (null-terminated) character strings in
compactString arrays so that each array is allocated the exact amount of memory
necessary to store a string. For example, the string "AbCdEf" is of length 6 and
requires 7 characters (including the trailing null character) for storage. So a
compactString storing this string should be allocated exactly 7 bytes of memory.
Implement functions for doing the following tasks on compactString arrays.
/* Initialize a compactString to the empty string. */ compactString initEmpty (); /* Reverse the compactString s and store in the compactString t */
void reverse ( compactString t , compactString s ) ; /* Append the character c to the compactString s */ void append ( compactString s , char c ) ; /* Insert the character c at the beginning of the compactString s */ void prepend ( compactString s , char c ) ; /* Delete and return the last character of the compactString s */ char delEnd ( compactString s ) ; /* Delete and return the first character of the compactString s */ char delStart ( compactString s ) ; /* Save to the compactString t the prefix of the compactString s of length n */ void prefix ( compactString t , compactString s , unsigned int n ) ; /* Save to the compactString t the suffix of the compactString s of length n */ void suffix ( compactString t , compactString s , unsigned int n ) ; /* Concatenate the compactString's s1 and s2 and store in the compactString t */ void concatenate ( compactString t , compactString s1 , compactString s2 ) ;
You should use dynamic memory management in order to ensure compact
representations of strings. Notice also that in the above prototypes, you should
allow the target string t to be the same as an input argument. For example, a prefix
of s may be stored in s itself. You should free unused allocated memory.
26. Memory allocation and reallocation on compactString's of the last exercise need
be carried out even when the size of the string changes by 1. In order to reduce
this overhead, let us plan to dynamically maintain the size allocated to each array
to be a power of 2. Whenever a string requires n bytes for storage, we actually
allocate 2t bytes, where 2t-1 < n <= 2t. In that case many operations require no
reallocation of memory. However, we need to maintain the actual amount of
memory allocated to a string. Define the following data type: 27. typedef struct { 28. char *data; 29. int allocSize; 30. } semiCompactString;
Rewrite the functions of the previous exercise for semiCompactString's.
31. A (univariate) polynomial is specified by an array of its coefficients. Since one
cannot check during program execution the size of a static or dynamic array, one
should additionally maintain the degree of a polynomial.
a. Define a structure to represent a polynomial with integer coefficients. The
coefficient array is to be maintained dynamically so that the exact amount
of memory needed to store the coefficient array is only allocated.
b. Write a function that, given a polynomial p (in this representation) and an
integer a, returns the value p(a).
c. Write functions to implement arithmetic routines (addition, subtraction
and multiplication) on polynomials. Each routine should be stingy enough
to (re)allocate to the output polynomial only the space just required to
store the coefficient array.
32. You are given a network of sensor nodes deployed in a battlefield. Each node is
specified by its id (an integer) and its location of deployment (two integer or
floating point numbers indicating the X and Y coordinates of the node with
respect to some fixed reference point). A sensor node can communicate with
another provided that they are physically located within 100 meters of one
another. In that case, the two nodes are called neighbors.
You are given a text file whose first line stores the number n of nodes in the
sensor network. In lines 2 through n+1 individual nodes are specified by three
numbers (id and coordinates). For simplicity, assume that the node ids are
0,1,2,...,n-1. Read the data from the file and store in a dynamic two-dimensional
array the list of neighbors of each node. The i-th row should store all the
neighbors of node i in sorted order (of id) and should be allocated exactly the
amount of memory needed to accommodate this list of neighbors.
33. A matrix is said to be sparse if each row of it contains only few non-zero entries.
Such sparse matrices occur in many situations. For example, a complicated
machine may have one million components, but each component interacts with at
most one hundred other components. So the interaction matrix, though million-
by-million in size, has at most one hundred non-zero entries in each row and may
be rightfully dubbed sparse.
In order to store a sparse matrix, it suffices to store for each row only the column
indices where non-zero entries occur and also the corresponding entries. This
reduces the space overhead associate with the storage. For the example in the last
paragraph, a complete million-by-million array requires space for storing one
trillion (1012) entries, whereas a sparse representation is capable of storing the
same matrix in a space for only one hundred million index-entry pairs.
a. Define a suitable dynamic two-dimensional array type to represent a
sparse matrix.
b. Write a function that computes the transpose of a sparse matrix (under this
representation).
c. Write a function that adds two sparse matrices.
d. Write a function that multiplies two sparse matrices.
Note that the transpose At of a sparse matrix A is not necessarily sparse. Some
rows of At may be quite dense. However, if the non-zero elements of A occur at
randomly chosen columns, then At is also sparse with high probability.
The product of two sparse matrices is expected to be much less sparse than the
arguments.
34. We generally deal with complex numbers of the form a + ib, where a and b are
floating point numbers. However, in the special case when both a and b are
integers, it may be desirable to store a and b as integers.
Define a structure holding the real and imaginary parts of a complex number
together with a flag. Depending on the value of the flag, the two parts of the
complex number are to be interpreted. If the flag has the zero value, the parts are
treated as floating point numbers. For any non-zero value of the flag, the parts are
treated as integers. Your structure should contain a union for the storage of the
two parts.
Write a routine to initialize a complex number to the zero value. The initialization
routine should also accept a value for the flag as an argument and set the real and
imaginary parts in the union accordingly.
Write the arithmetic routines on these complex numbers. Each argument in each
such routine may be of any type (pair of floating point numbers or of integers).
Your program should output an integer pair as the output complex number if both
the input arguments are integer pairs. If one or both of the input arguments is/are
floating point pair(s), the output should also be a floating point pair.
35. Write a program to solve the following problem. You are given a text file. Your
problem is to adjust the spaces in each line in such a way that the resulting text is
justified (both at the left and at the right). Here is our proposal of an algorithm
that you should use in order to perform text justification.
o First read the input file and store the lines in a two-dimensional character
array with each line stored in a single row of the array.
o The text is assumed to be divided into paragraphs. Two consecutive
paragraphs are separated by a blank line.
o The last line in a paragraph is not to be justified. Also a blank line is not to
be justified.
o Finally suppose that you have a non-blank line which is not the last line of
a paragraph. If the length of this line is already larger than the target
width, then do not perform any processing of this line. Otherwise, increase
the sizes of the inter-word gaps so that the resulting line has a width equal
to the target width. Assume that len denotes the initial length of the line
and that the line initially contains nsp number of inter-word spaces. You
have to add a total of o extra = target_width - len
number of additional spaces to the line. In order that the insertion leads to
(aesthetically) good-looking paragraphs, it is necessary to distribute the
extra new spaces more or less uniformly among the nsp inter-word gaps.
Let
q = extra / nsp (integer division).
First insert q additional spaces in each of the nsp gaps. If extra is not an
integral multiple of nsp, this still leaves us with
r = extra - q * nsp
spaces to be inserted. If the line has an odd number in the current
paragraph, add another single space in each of the first r gaps. On the
other hand, if the line has an even number in the current paragraph, add a
single space in each of the last r gaps.
Course home
CS13002 Programming and Data
Structures Spring
semester
Exercise set IV
Note: Students are encouraged to solve as many problems from this set as possible. Some
of these will be solved during the lectures, if time permits. We have made attempts to
classify the problems based on the difficulty level of solving them. An unmarked exercise
is of low to moderate difficulty. Harder problems are marked by H, H2 and H
3 meaning
"just hard", "quite hard" and "very hard" respectively. Exercises marked by M have
mathematical flavor (as opposed to computational). One requires elementary knowledge
of number theory or algebra or geometry or combinatorics in order to solve these
mathematical exercises.
1. Rewrite the dynamic linked list implementations of the ordered list, stack and
queue ADTs incorporating the feature that whenever a node is deleted, the
memory allocated to that node is freed.
2. Implement the ordered list, stack and queue ADTs with dynamic linked lists but
without using the dummy nodes at the beginning of the lists.
3. Dynamic arrays may be used to provide a third implementation of ordered list,
stack and queue ADTs. Here the array holding the list is to be allocated memory
dynamically depending on the size of the list. Implement the ADTs using
dynamic arrays.
4. Implement the ordered list ADT using doubly linked lists. (Recall from Exercise
set III that in a doubly linked list each node maintains two pointers, one pointing
to the next node in the list, the other to the previous node in the list.)
5. Write a function that takes as arguments two sorted linked lists and outputs a
sorted linked list obtained by merging the two input lists.
6. Think of the ordered list ADT modified using the following strategy. Whenever
an element is located using the isPresent() operation, that particular element is
deleted from the current position and reinserted at the beginning of the list. The
motivation behind this relocation is that in many situations an element accessed in
a list is expected with high probability to be accessed several times in the future.
So keeping the element near the beginning of the list reduces average search time.
Modify the ordered list ADT implementations to incorporate this modification.
7. Consider the ADT set that represents a collection of integers. The ADT should
support standard set operations: 8. S = init();
9. /* Initialize S to the empty set */
10.
11. isEmpty(S);
12. /* Return true if and only if S is the empty set */
13.
14. isSingleton(S);
15. /* Return true if and only if S contains only one element
*/
16.
17. isMember(S,a);
18. /* Return true if and only if a is a member of the set S
*/
19.
20. S = addElement(S,a);
21. /* Add the element a to the set S. If a is already in S,
22. there will be no change, else a new element is to be
inserted. */
23.
24. S = delElement(S,a);
25. /* Remove the element a from the set S. No change if a is
not
26. a member of S. */
27.
28. S = union(U,V);
29. /* Assign to S the union of the sets U and V */
30.
31. S = intersection(U,V);
32. /* Assign to S the intersection of the sets U and V */
33.
34. S = difference(U,V);
35. /* Assign to S the set difference U - V */
36.
37. S = symmDiff(U,V);
38. /* Assign to S the symmetric difference (U - V) union (V -
U) */
39.
40. print(S);
41. /* Print the elements of the set S */
42.
43. printSorted(S);
44. /* Print the elements of the set S in the sorted order. */
a. Implement the set ADT using static arrays.
b. Implement the set ADT using dynamic arrays.
c. Implement the set ADT using linked lists.
45. A multiset is like a set with the exception that each member of the set may be
present multiple times. For example, an aquarium is a multiset specified by the
different species of fish it contains and by the number of fish in the aquarium
belonging to each such species. For this exercise, concentrate on multisets of
integers (because integers are less fishy). The multiset ADT should support the
following operations. 46. S = init();
47. /* Initialize S to the empty multiset */
48.
49. isMember(S,a);
50. /* Return true if and only if a is a member of the
multiset S */
51.
52. count(S,a);
53. /* Return the number of occurrences of a in the multiset S
*/
54.
55. S = addElement(S,a,n);
56. /* Add n occurrences of a to the multiset S */
57.
58. S = delElement(S,a,n);
59. /* Delete n occurrences of a from the multiset S. If S
contains
60. less than n occurrences of a, only those many that are
present
61. in S need be deleted. */
62.
63. S = union(U,V);
64. /* Assign to S the union of the multisets U and V. If U
and V
65. respectively contain m and n occurrences of a, then
their
66. union would contain m+n occurrences of a. */
67.
68. S = intersection(U,V);
69. /* Assign to S the difference of the multisets U and V. If
U and V
70. respectively contain m and n occurrences of a, then
their
71. intersection would contain min(m,n) occurrences of a.
*/
72.
73. S = difference(U,V);
74. /* Assign to S the difference U - V. If U and V
respectively
75. contain m and n occurrences of a, then their difference
would
76. contain max(m-n,0) occurrences of a. */
77.
78. print(S);
79. /* Print all the elements of S with positive
multiplicities. Also
80. print the corresponding multiplicities. */
81.
82. printSorted(S);
83. /* Same as print(S) except that the elements are printed
in the
84. sorted order. */
a. Implement the multiset ADT using static arrays.
b. Implement the multiset ADT using dynamic arrays.
c. Implement the multiset ADT using linked lists.
85. [H] A nested ordered list of integers is recursively defined as follows: 86. The empty tuple () is a nested list.
87. If A0,A1,...,An-1 are nested lists or integers for some n >= 1,
88. then (A0,A1,...,An-1) is again a nested list.
Here are some examples:
()
(3,4,5)
((),3,(),(4),5)
(3,(4),((5),(),(6,7,(8))),((9),()))
A nested list should support the following functions:
L = init();
/* Initialize the nested list L to the empty list */
isEmpty(L);
/* Returns true if and only if L is the empty list */
L = insertInt(U,a,k);
/* If U = (U0,U1,...,Um-1) is a list and a an integer, then
assign to L the
nested list (U0,U1,...,Uk-1,a,Uk,...,Um-1). Report error if
k > m. */
L = insertList(U,V,k);
/* If U = (U0,U1,...,Um-1) is a list and V a nested list,
then assign to L the
nested list (U0,U1,...,Uk-1,V,Uk,...,Um-1). Report error if
k > m. */
L = join(U,V);
/* If U = (U0,U1,...,Um-1) and V = (V0,V1,...,Vn-1) are nested
lists,
assign to L the nested list (U0,U1,...,Um-1,V0,V1,...,Vn-1).
*/
L = joinAt(U,V,k);
/* If U = (U0,U1,...,Um-1) and V = (V0,V1,...,Vn-1) are nested
lists,
assign to L the nested list (U0,U1,...,Uk-1,V0,V1,...,Vn-
1,Uk,...,Um-1).
Report error if k > m. */
L = delete(U,k);
/* If U = (U0,U1,...,Um-1) is a nested list, assign to L the
nested list
(U0,U1,...,Uk-1,Uk+1,...,Um-1). Report error if k >= m. */
L = sublist(U,k,l);
/* If U = (U0,U1,...,Um-1) is a nested list, return the sub-
list (Uk,...,Ul).
Report error for improper indices k,l. */
print(L);
/* Print the nested list L as a fully parenthesized
expression. */
89. Implement the (univariate) polynomial ADT with all standard arithmetic
operations on polynomials. First mention the prototypes of the ADT functions and
then implement. Use dynamic memory management to implement the list of
coefficients.
90. A multivariate polynomial in n variables X1,...,Xn is a finite sum of terms of the
form aX1e1,...,Xn
en, with each ei being a non-negative integer and with the
coefficient a being an integer. Arithmetic operations on a multivariate polynomial
are carried out following standard rules.
a. Write the ADT functions for polynomials. Include standard arithmetic
operations and partial derivatives.
b. Assume that the number of variables is small, like 2 or 3. Implement the
ADT using static multi-dimensional arrays to store the coefficients.
c. Also implement the ADT using dynamic multi-dimensional arrays to store
the coefficients.
d. [H] Each term in a multivariate polynomial is identified by the coefficient
and the exponents. For example, the term aX1e1,...,Xn
en is determined by
the tuple (a,e1,...,en). So a structure capable of storing n+1 fields
suffices to store a term, and a polynomial is an array of such structures.
Use this strategy to implement the polynomial ADT. You should think of
a way to order the exponent tuples (e1,...,en) and store the terms sorted
under this ordering.
e. [H2] An n-variate polynomial can be thought of as a univariate polynomial
whose coefficients are (n-1)-variate polynomials. This recursive definition
provides us with yet another handle for implementing the polynomial
ADT. Use a nested linked list structure for a concrete recursive realization
of the multivariate polynomial ADT.
91. Suppose you want to implement two stacks in a single array. Two possibilities are
outlined here:
Odd-even strategy: Stack 1 uses locations 0,2,4,... of the array, whereas Stack 2
uses the array locations 1,3,5,...
Colliding strategy: The two stacks start from the two ends of the array and grow
in opposite directions (towards one another).
Implement both the strategies. Write two sets of initialize, push and pop
functions.
92. Write a function that uses the stack ADT calls in order to reverse a character
string.
93. [M] Suppose that you have a stack and push to the stack the integers 1,2,...,n in
that sequence. In between these push operations you also invoke some pop
operations in such a way that a pop request is never sent to an empty stack.
Immediately before each pop operation you also print the top of the stack. After
all of the integers 1,2,...,n are pushed, the elements remaining in the stack are
printed and popped resulting in an eventually empty stack. The printed integers
form a permutation of the integers 1,2,...,n. An example is given below for
n = 5: 94. S = init();
95. S = push(S,1);
96. S = push(S,2);
97. print top(S);
98. S = pop(S);
99. S = push(S,3);
100. S = push(S,4);
101. print top(S);
102. S = pop(S);
103. print top(S);
104. S = pop(S);
105. S = push(S,5);
106. print top(S);
107. S = pop(S);
108. print top(S);
109. S = pop(S);
This sequence prints the permutation:
2,4,3,5,1
Prove or disprove: All permutations of 1,2,...,n can be generated by a suitable
sequence of such push and pop operations.
110. Use stack ADT calls to recognize strings with balanced parentheses.
Examples of such strings: (), ((())), ()()(), ()(()(()(()()))). Non-
examples: ((), (()(()))), )()(.
111. Use stack ADT calls to recognize strings with balanced parentheses and
square brackets. Examples of such strings: (), ([()]), []()[],
25. The following function recursively determines whether a given string is a
palindrome. Determine its time complexity. 26. int isPalindrome ( char A[] , int n ) 27. { 28. if (n <= 1) return 1; 29. if (A[0] != A[n-1]) return 0; 30. return isPalindrome(&A[1],n-2); 31. }
32. Determine the time complexity of the following iterative function: 33. int f ( int A[SIZE][SIZE] , int n ) 34. { 35. int i, j, sum = 0; 36. 37. for (i=0; i<n; ++i) { 38. if (i % 2 == 0) 39. for (j=0; j<=i; j=j+1) sum = sum + A[i][j]; 40. else 41. for (j=n-1; j>=i; j=j-1) sum = sum - A[i][j]; 42. } 43. }
44. [H] Write a function that accepts a positive integer n and prints all permutations
of 1,2,3,...,n. Assume that printing a single integer is a basic operation and
establish the time complexity of your function.
45. Establish that merging two sorted arrays each of size n/2 can be done in O(n)
time.
46. Establish that merging two sorted linked lists each of size n/2 can be done in
O(n) time.
47. [H] Write the sorting routines (bubble, insertion, selection, quick and merge sorts)
for linked lists. Each routine should have the same time complexity as the
corresponding routine on arrays.
48. Consider the Tower of Hanoi problem of Exercise set II. Solve the problem using
the algorithm that first recursively moves the top n-1 disks from Peg A to Peg C
using Peg B as an auxiliary location, then moves the largest disk from Peg A to
Peg B, and finally moves the n-1 disks from Peg C to Peg B using Peg A as an
auxiliary location. Let T(n) denote the number of disk movements done by the
algorithm for n disks.
a. Show that T(n) satisfies the following recurrence: b. T(1) = 1, c. T(n) = 2T(n-1) + 1 for n >= 2.
d. Prove that T(n) = 2n - 1 for all n >= 1.
e. [HM] Argue that any algorithm that solves the Tower of Hanoi problem
must make at least 2n - 1 disk movements. (Hint: Consider the instance
when the largest disk is removed from Peg A.)
49. Compare the running times of the insertion and deletion functions in our
implementations of the ordered list, stack and queue ADTs. Express the running
time in terms of the current size n (number of elements) of the list (or stack or
queue).
50. [H2] In this exercise we plan to compute the binomial coefficient C(n,k). Several
algorithms are proposed to that effect. These algorithms vary widely in their time
complexities ranging from polynomial to truly exponential.
a. We know that binomial coefficients satisfy the recurrence relation: b. C(n,k) = C(n-1,k) + C(n-1,k-1)
for suitable values of n,k. Write a recursive function that straightaway
uses this recurrence relation. Use suitable boundary conditions so that
recursion eventually terminates.
c. Deduce that the running time of the recursive algorithm of Part a) is
exponential and not polynomial in n. For computing the running time, take
k <= n.
d. Use a two-dimensional auxiliary array to keep track of the pairs (m,j) for
which C(m,j) has already been computed. If the value is available,
replace a recursive call for computing C(m,j) by reading the value from
the auxiliary array. This technique is known as memoization.
e. Deduce that the running time of the recursive routine with memoization is
polynomial in n.
f. Write an iterative routine that generates the Pascal triangle in the
following order: C(0,0), C(1,0), C(1,1), C(2,0), C(2,1),
C(2,2), ... till the value of C(n,k) is computed. The top-down
algorithm of Part a) recomputes many C(m,j) values multiple times. The
bottom-up technique of this part is an example of dynamic programming.
g. Deduce that the iterative algorithm of Part e) runs in time polynomial in n.
h. Use the formula i. C(n,k) = n(n-1)...(n-k+1) / k!
to compute the value of C(n,k).
j. Argue that the running time of the algorithm of Part g) is polynomial in n.
k. Compare the space complexities of the above four algorithms.
51. Suppose we want to compute the transpose of a matrix A and store the result in A
itself. We do not assume A to be necessarily a square matrix.
a. Write a function that takes an m x n matrix A as input, computes in a local
matrix B the transpose of A and finally copies B back to A. What is the
space complexity of this function?
b. [H] Write a function that computes At in A using only a constant amount of
additional storage.
52. Write a function that takes a square matrix A as input and computes in A itself the
matrix A - At using only O(1) additional storage. (Hint: The matrix A - At is
anti-symmetric, i.e., its (j,i)-th element is the negative of its (i,j)-th element.)
53. Let A be an n x n matrix.
a. Write a function that converts A to row-reduced echelon form in O(n3)
time using elementary row operations only.
b. Write a function that computes the determinant of A in O(n3) time.
54. Write a function that computes the rank of an n x n matrix in O(n3) time.
55. Write a function that inverts an n x n matrix in O(n3) time.
56. Write a function that, given a square system 57. Ax = b
of linear equations, determines a solution for x, provided that the system is
solvable. Your function should run in a time polynomial in the size (number of
variables or equations) of the system. Your function should handle
underdetermined (but consistent) systems, i.e., systems that have multiple
solutions.
58. In this exercise a sparse matrix denotes a square matrix having only few (constant
numbers of) non-zero elements in each row.
a. Define a data type for storing a sparse matrix.
b. Write a function that adds two n x n sparse matrices in O(n) time.
c. [H] Write a function that multiplies two n x n sparse matrices in O(n2)
time.
Notice that the complexities of addition and multiplication of dense (non-sparse)
n x n matrices are O(n2) and O(n3) respectively. The sparse representation