Return to lecture notes index

15-100 Lecture 19 (Friday, Friday, February 29, 2008)

Today's Example

Today, we're going to create a generic ArrayList based on an array of objects. The idea is that we'll be able to add and remove things from this ArrayList, 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.

The Storage

Storage within the ArrayList is based on an array of Objects. We choose to create an array of Objects, becuase it enables this ArrayList 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 ArrayList 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 ArrayList {

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

  public ArrayList() {
    items = new Object[DEFAULT_SIZE];
    count = 0;
  }
  
  
  public ArrayList (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;
  }

The contains(...) Method

The contains(...) method is really an extension of the same traveral we used for toString(). We walk through each item within the Collection, from [0...(.length-1)]. The only difference is that, for each item, we check to see if it matches the one for which we are searching using equals(). If it does, we immediately return true -- we are done, there is no reason to continue the traversal. If however we work our way through the entire array and don't find a match, we return false -- there is no where else to look.

  public boolean contains (Object item) {
  
    for (int index=0; index < nextSlot; index++) {
      if (items[index].equals(item)) 
        return true;
    }
    
    return false;
  
  }

Add Last and Add First

The add last method is what we have been doing up until now, inserting the item at the very end of the array. So what's different about inserting an item into the beginning of the array?

Well, we can't just set items[0] equal to the item we're inserting. This is because whatever was at items[0] would just get overwritten. So what we have to do is shift everything over in the array to make space for the new item. The easiest way to do this is to start at the end of the array and start pulling stuff over. In array of n items, count will be equal to n and we'll have the last item stored at n-1 (0, 1, 2... n-1 are our indicies). So this means that index n should be an empty space (unless the array is full, in which case we'll grow() just like our normal add method). So we need to start at n and and set index[n] equal to index[n-1], then do the same thing for n-1. We do this index[n-1] = index[n-2] all the way until index[1] = index[0], in which case we've shifted everything over and we go ahead and set items[0] equal to our item being inserted. Let's take a look at some code:


  public void addFirst(Object o) {
    if (count == items.length)
      grow();
  
    for (int holeIndex=count; holeIndex > 0; holeIndex--) {
      items[holeIndex] = items[holeIndex-1];
      items[holeIndex-1] = null;
    }
    
    items[0] = o;
    
    count++;
   }

Add Before

This method will take in two items, an item to insert, and an item to insert before. This will look similar to our AddFirst method in the sense that we have to shift items over to make room, since we're not adding to the end of the array again.

Again, we'll start at the end of the array and walk backwards, shifting items over. The difference is that this time we want to stop after we've shifted the item to insert before. For example if we try to insert D before E in the following example:

	
	0 1 2 3 4 5
	A B C E F

	5 is our index so we
	set items[5] to items[4]
	0 1 2 3 4 5
	A B C E F F

	4 is our index so we
	set items[4] to items[3]
	0 1 2 3 4 5
	A B C E E F

	items[4] is equal to E
	so we stop shifting items over
	and insert at items[3]
	0 1 2 3 4 5
	A B C D E F

Notice that when we shift items over, we don't "get rid of" or set to null the old index so we end up with two spots have F in them. This is because we know that in the next step, the original F will be overwritten. This process continues all the way until we find the item to insert before (E) in which case the item to insert takes the place of the original E.

We'll make this method return type boolean because its possible that the item to insert before is not within our array in which case we can't perform the requested operation.

 public boolean addBefore (Object addMe, Object beforeMe) {
   
     if (!contains(beforeMe)) return false;
     
     if (count == items.length)
      grow();
 
     int holeIndex=-1;
     for (holeIndex=count-1;    ; holeIndex--) {
       items[holeIndex+1] = items[holeIndex];
       if (items[holeIndex+1].equals(beforeMe))
         break;
     }
     
     items[holeIndex] = addMe;
     count++;

     return true;
   
   }

ArrayList, so far

class ArrayCollection {

  private Object[] items;
  private int count;
  
  private static final int DEFAULT_SIZE = 100;
  private static final int GROWTH_COEFF = 2;
  
  
  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;
  }
  
  
  public ArrayCollection(int size) {
    items = new Object[size];
    count = 0;
  }
  
  
  public ArrayCollection() {
    items = new Object[DEFAULT_SIZE];
    count = 0;
  }
  
  
  public void addLast(Object item) {
    if (count == items.length)
      grow();
  
    items[count++] = item;
  }


  public void addFirst(Object o) {
    if (count == items.length)
      grow();
  
    for (int holeIndex=count; holeIndex > 0; holeIndex--) {
      items[holeIndex] = items[holeIndex-1];
      items[holeIndex-1] = null;
    }
    
    items[0] = o;
    
    count++;
   }
   
   public boolean addBefore (Object addMe, Object beforeMe) {
   
     if (!contains(beforeMe)) return false;
     
     if (count == items.length)
      grow();
 
     int holeIndex=-1;
     for (holeIndex=count-1;    ; holeIndex--) {
       items[holeIndex+1] = items[holeIndex];
       if (items[holeIndex+1].equals(beforeMe))
         break;
     }
     
     items[holeIndex] = addMe;
     count++;

     return true;
   
   }
   
   
   


  public boolean contains (Object o)  {
    
    for (int index=0; index < count; index++)
      if (items[index].equals(o)) return true;
    
    return false;
  }
}