Return to lecture notes index

15-111 Lecture 4 (Wednesday, January 21, 2009)

Review of Arrays

Consider a list of Strings declared independently. They are not associated with each other. And, there is no way of selecting the 0th, 1st, 2nd, 3rd, &c. And, furthermore, there is no way to declare them dynamically, once the program begins execution.


   String s0;
   String s1;
   String s2;
   String s3;
   String s4;

Arrays allow us to dynamically contruct an ordered, indexed list of items. Consider the code below. It creates a new array of 4 Strings and then assigns the 2nd (0th, 1st, 2nd, ...) a reference to "Greg".


   String[] names;
   names = new String[4];
   names[2] = "Greg";

Once we've done this for names[2], the array looks like this:


               names[0]    names[1]  names[2]   names[3]
             --------------------------------------------
  names -->  |    *     |   null   |     *    |  null   |
             |          |          |     ||   |         |
             ----------------------------||--------------
                                         ||
                                         \/
                                    +----------+
                                    |  "Greg"  |
                                    +----------+

There is also a field for all arrays called length that is publicly accessible. The line "System.out.println(names.length);" will print out 5 because that is the size that we made our array. Note that since we are examining a public field and not a method: There are no parentheses after length.



import java.util.*;
import java.io.*;


class ArrayStuff {

  public static void main (String[] args) {
    Scanner keyboard = new Scanner (System.in);
    String[] names;
    
    
        
    System.out.print ("How many names? ");
    int numberOfNames = keyboard.nextInt();
    names = new String[numberOfNames];
    keyboard.nextLine(); // Throw away newline after number
    
    for (int nameIndex=0; nameIndex < numberOfNames; nameIndex++) {
      System.out.print ("Name: "); 
      names[nameIndex] = keyboard.nextLine();
    }
          
    System.out.println ("");
    System.out.println ("Names in reverse:");
          
    //Prints out the names in reverse order they were put in
    for (int nameIndex=(numberOfNames-1); nameIndex >=0; nameIndex--) {
      System.out.println (names[nameIndex]);
    }
    //Prints out the length of the array we created (numberOfNames>
    System.out.println("Just printed " + names.length + " names.");
  }
  
}

A Value-Added Example

Next, let's create a generic Container based on an array of objects. The idea is that we'll be able to add and remove things from this Container, as well as to determine if an Object is already within the container.

The example serves to illustrate the use of arrays -- both their syntax and sematics, and also how they can be used in practice. It is also very similar to the Java ArrayList and Vector classes. If you don't remember the ArrayList class and the List interface, please check out the Java API documentation available from Sun.

The Storage

Storage within the Container is based on an array of Objects. We choose to create an array of Objects, becuase it enables this Container to hold anything, because all objects are derived from Object and all primitives can be wrapped within Objects (more on that soon).

We initialize the array when the Container is created, via the constructor. But, how big should it be? We'll answer that question two ways:

...this way, we can accomodate the expected common cases -- and also much larger cases.

We also keep an instance variable, count, that serves to tell us the next available slot in the array -- in other words, where we put the next item. Since the array's first index is 0, this variable also contains the count of items -- it is initially 0 and remains one ahead of the most recently added item.

In creating the class, we'll use a "final" variable to set the default size. Since this is a definition, not a property of an instance, we make it "static". This type of variable is known as a "configuration constant". This is because it can be changed, a.k.a., configured, but only before the program is compiled, hence "constant" during the program's execution.

class Containter {

  private Object[] items;
  private int count;
  
  private static final int DEFAULT_SIZE = 10;
  

  public Container() {
    items = new Object[DEFAULT_SIZE];
    count = 0;
  }
  
  
  public Container (int size) {
    items = new Object[size];
    count = 0;
  }

}

The add(...) Method

The add method is, in principle, very straight-forward. We have our state variable, count, that maintains the position of the next available slot. We drop our item off into this slot and then advance the count counter.

About the only thing that can go wrong is that the array is already full. If the array is already full, (count == items.length). This is because an array of length items.length has indexes [0...(items.length-1)] -- but not items.length. Should we try to access an element outside of the legal bounds, an ArrayIndexOutOfBoundsException will be thrown. This is an "unreported" exception, so it doesn't need to be "declared thrown", but it certainly can and will arise.

Notice that after adding an item, we bump count forward -- this gets us ready to to it again for the next one.


  public void add (Object item) {
    if(count == items.length)
	grow();
    items[count++] = item;
  }

The grow() Method

Fortunately, that's not as hard as it seems. Remember that "list" is not an array -- it is a reference variable. It identifies an array. The user accesses the array only indirectly through it. This is called "indirection". And, the great thing about it is that the user doesn't need to know which array object is being used -- just that it is referenced by "list".

This makes it possible for us to play the shell game and switch the array underneath list from the small, filled array to a new bigger one. And, the user of list will never know the difference.

So, the solution looks like this:

  1. We create a new,bigger array
  2. We copy each reference from the old array to the new array, keeping it at the correpsonding index. Item 0 to item 0, item 1 to item 1, &c.
  3. We change "list" to reference the new array instead of the old array
  4. The garbage collector is now free to take care of the unreferenced old array.
  5. The user can't tell the difference -- they are still using list and everything is still at the same position.
So, our basic approach will be to create a new, bigger array to replace the old one. But, how much bigger? In a technical sense, we solve the problem if we make it only one slot larger -- then our insert can succeed.

But the problem is that we'll end up growing the array each time -- and that ain't cheap. Consider an array with 1,000,000,000 items. It it full, so we create one with 1,000,000,001 items. This involves copying the references for each fo the original 1,000,000,000 items. Now, we add one more, so we copy 1,000,000,001 items. And, each time we insert, we'll end up doing the same thing.

Ouch! So, we clearly want to grow by more than one. Typically these data structures grow by doubling. This way, there is plenty of room to grow. In 15-211 you'll learn that this approach leads to a reasonable average cost, known as :"amortized constant time". For now, we can just use our intuition and see that we end up copying a lot less this way. We're trading space for time -- a classic trade.

But, since this trade might be made differently if we knew that the array was large and wouldn't grow very often versus if it were small and dynamic, we make this growth factor a "configuration constant". A constant that can be changed in one place at compile time.

 private void grow() {
    Object[] biggerArray = new Object[GROWTH_COEFF * items.length];
  
    for (int index=0; index < items.length; index++)
      biggerArray[index] = items[index];
  
    items = biggerArray;
  }

Container, so far

class Containter {

  private Object[] items;
  private int count;
  
  private static final int DEFAULT_SIZE = 10;
  private static final int GROWTH_COEFF = 2;
  

  public Container() {
    items = new Object[DEFAULT_SIZE];
    count = 0;
  }
  
  
  public Container (int size) {
    items = new Object[size];
    count = 0;
  }
  

  public void add (Object item) {
    if(count == items.length)
	grow();
    items[count++] = item;
  }

  private void grow() {
    Object[] biggerArray = new Object[GROWTH_COEFF * items.length];
  
    for (int index=0; index < items.length; index++)
      biggerArray[index] = items[index];
  
    items = biggerArray;
  }

}

How Fast Are Array Accesses

One of the beautiful things about an array is that it is possible to skip around an array without penalty. In other words, an access to an element of an array takes about the same amount of time, regardless of the index, regardless of the size of the array, and regardless of the pattern of access.

It doesn't take any longer to access an element in a really big array than it does in a really small one. It isn't significantly faster to access elements of an array sequentially than it is to access them in a random order.

The magic isn't really software, so much as it is hardware. A computer's member is basically one really large array. And, at the hardware level, any location, a.k.a. address, within memory can be accessed in the same amount of time. The only trick at the software level is mapping the array into memory.

A computer's memory is byte addressed. In other word, each byte (8-bits, enough space to hold a number between 0-255), is numbered. This number is called the address. When the hardware is presented the address, it returns the value of the byte at that address. When a larger object is stored in memory, the address of the beginning of the object is considered to be the object's address.

If we consider an array of 4-bye ints beginning at address 100, they would be organized as below:

    int numbers[] = new int[3];
    100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 
    --------------  --------------- --------------  ---------------
    numbers[0]      numbers[1]      numbers[2]      numbers[3]

Our objects are generally larger than one byte (never smaller). So, for a one dimensional array, the system can quickly find the address of any element as below. Compare it to the figure above. Convice yourself that the formula works -- and of why.

  object_address + size_of_type*index

Now, consider the formula again. Notice that it requires one add and one multiply -- regardless of the size of the array, regardless of the size of the data type, and regardless of the address being accessed.

When mapping a multidimensional array into memory, things do get a bit more tangled -- but the same principle applies. Consider the 3x3 array shown below. Notice how we map a 2-dimensional array of ints into a 1-dimensional array (memory):

    int numbers[][] = new int[3][3];

    numbers[0][0]   numbers[0][1]   numbers[0][2] 
    numbers[1][0]   numbers[1][1]   numbers[1][2] 
    numbers[2][0]   numbers[2][1]   numbers[2][2] 



    100 101 102 103 104 105 106 107 108 109 110 111 
    --------------  --------------- --------------- 
    numbers[0][0]   numbers[0][1]   numbers[0][2] 


    112 113 114 115 116 117 118 119 120 121 122 123
    --------------  --------------- --------------- 
    numbers[1][0]   numbers[1][1]   numbers[1][2] 


    124 125 126 127 128 129 130 131 132 133 134 135
    --------------  --------------- --------------- 
    numbers[2][0]   numbers[2][1]   numbers[2][2] 

So, here's the name of the game. In order to project the 2D array into the 1D memory space, we first project the rows, then we find our position within each row. So, first we find the beginning of each row, as below:

  number[row_number] = object_address + row_number * (size_of_type*number_of_cols)

Now, within a row, we are just playing with a 1D array, so the formula doesn't change:

  offset_within_row = numbers[row_number] + size_of_type*column_number]

If we put the two together, we get the whole answer:

  numbers[row_number][column_number] = object_address +
                                       row_number * (size_of_type*number_of_cols) + 
                                       numbers[row_number] + size_of_type*column_number]

This operation requires more computation than a 1D array. But, as with a 1D array, notice that neither the complexity nor the quantity of the required computation changes as the array size or indexes change.