Note 7: Hashing Concept in Data Structure for Application

 

Hashing

We have all used a dictionary, and many of us have a word processor equipped with a limited dictionary, that is a spelling checker.  We consider the dictionary, as an ADT.  Examples of dictionaries are found in many applications, including the spelling checker, the thesaurus, the data dictionary found in database management applications, and the symbol tables generated by loaders, assemblers, and compilers.

In computer science, we generally use the term symbol table rather than dictionary, when referring to the ADT.  Viewed from this perspective, we define the symbol table as a set of name-attribute pairs.  The characteristics of the name and attribute vary according to the application.  For example, in a thesaurus, the name is a word, and the attribute is a list of synonyms for the word; in a symbol table for a compiler, the name is an identifier, and the attributes might include an initial value and a list of lines that use the identifier.

Generally we would want to perform the following operations on any symbol table:

(1)   Determine if a particular name is in the table

(2)   Retrieve the attributes of that name

(3)   Modify the attributes of that name

(4)   Insert a new name and its attributes

(5)   Delete a name and its attributes

 

There are only three basic operations on symbol tables: searching, inserting, and deleting.  The technique for those basic operations is hashing.  Unlike search tree methods that rely on identifier comparisons to perform a search, hashing relies on a formula called the hash function.  We divide our discussion of hashing into two parts: static hashing and dynamic hashing.

 

Static Hashing

Hash Tables

In static hashing, we store the identifiers in a fixed size table called a hash table.  We use an arithmetic function, f, to determine the address, or location, of an identifier, x, in the table.  Thus, f (x) gives the hash, or home address, of x in the table.  The hash table ht is stored in sequential memory locations that are partitioned into b buckets, ht [0], …, ht [b-1].  Each bucket has s slots.  Usually s = 1, which means that each bucket holds exactly one record.  We use the hash function f (x) to transform the identifiers x into an address in the hash table.  Thus, f (x) maps the set of possible identifiers onto the integers 0 through b-1.

 

Definition

The identifier density of a hash table is the ratio n/T, where n is the number of identifiers in the table.  The loading density or loading factor of a hash table is a=n /(sb).

 

Example 7.1: Consider the hash table ht with b = 26 buckets and s = 2.  We have n = 10 distinct identifiers, each representing a C library function.  This table has a loading factor, a, of 10/52 = 0.19.  The hash function must map each of the possible identifiers onto one of the number, 0-25.  We can construct a fairly simple hash function by associating the letter, a-z, with the number, 0-25, respectively, and then defining the hash function, f (x), as the first character of x.  Using this scheme, the library functions acos, define, float, exp, char, atan, ceil, floor, clock, and ctime hash into buckets 0, 3, 5, 4, 2, 0, 2, 5, 2, and 2, respectively. 

 

 

Hashing Function

A hash function, f, transforms an identifier, x, into a bucket address in the hash table.  We want to hash function that is easy to compute and that minimizes the number of collisions.  Although the hash function we used in Example 7.1 was easy to compute, using only the first character in an identifier is bound to have disastrous consequences.  We know that identifiers, whether they represent variable names in a program, word in a dictionary, or names in a telephone book, cluster around certain letters of the alphabet.  To avoid collisions, the hash function should depend on all the characters in an identifier.  It also should be unbiased.  That is, if we randomly choose an identifier, x, from the identifier space (the universe of all possible identifiers), the probability that f(x) = i is 1/b for all buckets i.  This means that a random x has an equal property a uniform hash function. 

There are several types of uniform hash functions, and we shall describe four of them.  We assume that the identifiers have been suitably transformed into a numerical equivalent.

 

Mid-square:

The middle of square hash function is frequently used in symbol table application.  We compute the function fm by squaring the identifier and then using an appropriate number of bits from the middle of the square to obtain the bucket address.  Since the middle bits of the square usually depend upon all the characters in an identifier, there is a high probability that different identifiers will produce different hash addresses, even when some of the characters are the same.  The number of bits used to obtain the bucket address depends on the table size.  If we use r bits, the range of the values is 2r.  Therefore, the size of the hash table should be a power of 2 when we use this scheme.

 

Division:

This hash function is using the modulus (%) operator.  We divide the identifier x by some number M and use the remainder as the hash address of x.  The hash function is:  fD (x) = x % M   This gives bucket address that range from 0 to M-1, where M = the table size.  The choice of M is critical.  In the division function, if M is a power of 2, then fD (x) depends only on the least significant bits of x.  Such a choice for M results in a biased use of the hash table when several of the identifiers in use have the same suffix.  If M is divisible by 2, then odd keys are mapped to odd buckets, and even keys are mapped to even buckets.  Hence, an even M results in a biased use of the table when a majority of identifiers are even or when majorities are odd.

 

Folding:

In this method, we partition the identifier x into several parts.  All parts, except for the last one have the same length.  We then add the parts together to obtain the hash address for x.  There are two ways of carrying out this addition.  In the first method, we shift all parts except for the last one, so that the least significant bit of each part lines up with the corresponding bit of the last part.  We then add the parts together to obtain f(x).  This method is known as shift folding.  The second method, know as folding at the boundaries, reverses every other partition before adding.

 

Digit Analysis:

The last method we will examine, digit analysis, is used with static files.  A static file is one in which all the identifiers are known in advance.  Using this method, we first transform the identifiers into numbers using some radix, r.  We then examine the digits of each identifier, deleting those digits that have the most skewed distributions.  We continue deleting digits until the number of remaining digits is small enough to give an address in the range of the hash table.  The digits used to calculate the hash address must be the same for all identifiers and must not have abnormally high peaks or valleys (the standard deviation must be small).

 

 

Overflow Handling

There are two methods for detecting collisions and overflows in a static hash table; each method using different data structure to represent the hash table. 

Tow Methods:

Linear Open Addressing (Linear probing)

Chaining

 

Linear Open Addressing

When use linear open addressing, the hash table is represented as a one-dimensional array with indices that range from 0 to the desired table size-1.  The component type of the array is a struct that contains at least a key field.   Since the keys are usually words, we use a string to denote them.  Creating the hash table ht with one slot per bucket is:

 

#define   MAX_CHAR   10  /* max number of characters in an identifier */

#define   TABLE_SIZE   13 /*max table size = prime number */

 

struct   element

{

            char   key[MAX_CHAR];

            /*    other fields   */

};

 

element   hash_table[TABLE_SIZE];

 

            Before inserting any elements into this table, we must initialize the table to represent the situation where all slots are empty.   This allows us to detect overflows and collisions when we insert elements into the table.  The obvious choice for an empty slot is the empty string since it will never be a valid key in any application.

 

Initialization of a hash table:

void   init_table ( element   ht[ ] )

{

            short   i;

            for ( i = 0; i < TABLE_SIZE; i ++ )

                        ht [ i ].key[0] = NULL;

}

 

            To insert a new element into the hash table we convert the key field into a natural number, and then apply one of the hash functions discussed in Hashing Function.  We can transform a key into a number if we convert each character into a number and then add these numbers together.  The function transform (below) uses this simplistic approach.  To find the hash address of the transformed key, hash (below) uses the division method.

 

short   transform (char   *key )

{/* simple additive approach to create a natural number that is within the integer range */

            short   number = 0;

            while (*key)

                        number += *key++;

           

            return   number;

}

 

short   hash (char   *key)

{/* transform key to a natural number, and return this result modulus the table size */

 

            return   (transform (key) % TABLE_SIZE);

}

 

            To implement the linear probing strategy, we first compute f (x) for identifier x and them examine the hash table buckets ht[(f(x) + j) % TABLE_SIZE], 0 £ j £ TABLE_SIZE in this order.  Four outcomes result from the examination of a hash table bucket:

(1)               The bucket contains x.  In this case, x is already in the table.  Depending on the application, we may either simply report a duplicate identifier, or we may update information in the other fields of the element.

(2)               The bucket contains the empty string.  In this case, the bucket is empty, and we may insert the new element into it.

(3)               The bucket contains a nonempty string other than x.  In this case we proceed to examine the next bucket.

(4)               We return to the home bucket ht [f (x)] (j = TABLE_SIZE).  In this case, the home bucket is being examined for the second time and all remaining buckets have been examined.  The table is full and we report an error condition and exit.

 

Implementation of the insertion strategy:

void   linear_insert (element   item,   element   ht [ ] )

{  /*  insert the key into the table using the linear probing technique, exit the function if the table is full */

 

            short   i, hash_value;

            hash_value = hash (item.key);

            i = hash_value;

            while (strlen (ht [i].key)

            {

                        if (! strcmp (ht [i].key, item.key)

                        {

                                    cout << “Duplicate entry !\n”;

                                    exit (1);

                        }

                        i = (i+1) % TABLE_SIZE;

                        if (i = = hash_value)

                        {

                                    cout << “The table is full !\n”;

                                    exit (1);

                        }

            }

            ht [i] = item;

}

 

Chaining

            Linear probing and its variations perform poorly because inserting an identifier requires the comparison of identifiers with different hash values.  To insert a new element we would only have to compute the hash address f (x) and examine the identifiers in the list for f (x).  Since we would not know the sizes of the lists in advance, we should maintain them as linked chains.  We now require additional space for a link field.  Since we will have M lists, where M is the desired table size, we employ a head node for each chain.  These head nodes only need a link field, so they are smaller than the other nodes.  We maintain the head nodes in ascending order, 0,…….., M-1 so that we may access the lists at random.  The C++ declarations required to create the chained hash table are:

#define   MAX_CHAR   10 /* maximum identifier size*/

#define   TABLE_SIZE   13 /* prime number */

#define   IS_FULL (ptr) (!(ptr))

 

struct   element
{

            char   key[MAX_CHAR];

            /* other fields */

};

typedef   struct   list   *lis_pointer;

struct   list

{

            element   item;

            list_pointer   link;

};

list_pointer   hash_table[TABLE_SIZE];

 

The function chain_insert (below) implements the chaining strategy.  The function first computes the hash address for the identifier.  It then examines the identifiers in the list for the selected bucket.  If the identifier is found, we print an error message and exit.  If the identifier is not in the list, we insert it at the end of the list.  If list was empty, we change the head node to point to the new entry.

 

Implementation of the function chain_insert:

void   chain_insert (element   item,   list_pointer   ht[])

{ /* insert the key into the table using chaining */

            short   hash_value = hash (item.key);

            list_pointer   ptr, trail = NULL, lead = ht [hash_value];

            for( ; lead; trail = lead, lead = lead->link)

            {         

                        if (!strcmp(lead->item.key, item.key))

                        {

                                    cout << “The key is in the table \n”;

                                    exit (1);

                        }

            }

            ptr = new struct list;

            if (IS_FULL (ptr))

            {

                        cout << “The memory is full \n”;

                        exit (1);

            }

            ptr->item = item;

            ptr->link = NULL;

            if (trail)

                        trail->link = ptr;

            else

                        ht [hash_value] = ptr;

}