数据结构与算法(三)动态数组

245 阅读5分钟

什么是数据结构?

数据结构是计算机存储、组织数据的方式

-w393 -w378 -w360

在实际应用中,根据使用场景来选择最合适的数据结构

线性表

线性表是具有n个相同类型元素的有限序列(n ≥ 0)

-w1266

  • a1是首节点(首元素),an是尾结点(尾元素)
  • a1是a2的前驱,a2是a1的后驱

常见的线性表有数组、链表、栈、队列、哈希表(散列表)

数组(Array)

数组是一种顺序存储的线性表,所有元素的内存地址是连续的

-w752

在很多编程语言中,数组都有一个缺点:无法动态修改容量

在实际开发中,我们更希望数组的容量是可以动态改变的

动态数组的设计

下面我们就来自己设计一个动态数组

首先,我们需要确定对外的接口有哪些?

-w850

完整的设计代码如下

public class ArrayList<E> {
  /**
   * 元素的数量
   */
  private int size;
	
  /**
   * 所有的元素
   */
  private E[] elements;

  // static 修饰静态常量
  // final 常量(等同于const)
  // 常量名用大写
  private static final int DEFAULT_CAPACITY = 10;
  private static final int ELEMENT_NOT_FOUND = -1;
	
  public ArrayList(int capaticy) {
    capaticy = (capaticy < DEFAULT_CAPACITY) ? DEFAULT_CAPACITY : capaticy;
    elements = (E[]) new Object[capaticy]; // 所有的类,最终都继承java.lang.Object
  }
	
  public ArrayList() {
    this(DEFAULT_CAPACITY);
  }
	
  /**
   * 清除所有元素
   */
  public void clear() {
    // 防止对象在堆空间占用内存,所以要清空释放所有对象
    for (int i = 0; i < size; i++) {
      elements[i] = null;
    }
    size = 0;
    
    if (elements != null && elements.length > DEFAULT_CAPACITY) {
      elements = (E[]) new Object[DEFAULT_CAPACITY];
    }
  }

  /**
   * 元素的数量
   * @return
   */
  public int size() {
    return size;
  }

  /**
   * 是否为空
   * @return
   */
  public boolean isEmpty() {
    return size == 0;
  }

  /**
   * 是否包含某个元素
   * @param element
   * @return
   */
  public boolean contains(E element) {
    return indexOf(element) != ELEMENT_NOT_FOUND;
  }

  /**
   * 添加元素到尾部
   * @param element
   */
  public void add(E element) {
    add(size, element);
  }

  /**
   * 获取index位置的元素
   * @param index
   * @return
   */
  public E get(int index) {
    rangeCheck(index);
    return elements[index];
  }

  /**
   * 设置index位置的元素
   * @param index
   * @param element
   * @return 原来的元素ֵ
   */
  public E set(int index, E element) {
    rangeCheck(index);
	
    E old = elements[index];
    elements[index] = element;
    return old;
  }

  /**
   * 在index位置插入一个元素
   * @param index
   * @param element
   */
  public void add(int index, E element) {
    rangeCheckForAdd(index);
	
    ensureCapacity(size + 1);
	
    for (int i = size; i > index; i--) {
      elements[i] = elements[i - 1];
    }
    elements[index] = element;
    size++;
  }

  /**
   * 删除index位置的元素
   * @param index
   * @return
   */
  public E remove(int index) {
    rangeCheck(index);
	
    E old = elements[index];
    for (int i = index + 1; i < size; i++) {
      elements[i - 1] = elements[i];
    }

    // 先减少,然后再清空最后一个位置
    elements[--size] = null;
    
    // 优化:如果容量过大进行缩容
    trim();
	
    return old;
  }
	
  public E remove(E element) {
    remove(indexOf(element));
    return element;
  }

  /**
   * 查看元素的索引
   * @param element
   * @return
   */
  public int indexOf(E element) {
    if (element == null) {  // 判断是否为null,容错null调用函数的报错
    for (int i = 0; i < size; i++) {
      // 如果为null,用数组里的每一个元素判断是否为空
      // 而且为null的话,也不能用Object.equals来判断,会报错
      if (elements[i] == null) return i;
    }
  } else {
    for (int i = 0; i < size; i++) {
      // 如果不为null,就可以直接用该元素和数组里的每个元素进行比较
      if (element.equals(elements[i])) return i; // n
    }
  }
  return ELEMENT_NOT_FOUND;
}
	
  /**
   * 保证要有capacity的容量
   * @param capacity
   */
  private void ensureCapacity(int capacity) {
    int oldCapacity = elements.length;
    if (oldCapacity >= capacity) return;
	
    // 新容量为旧容量的2倍
    // 位运算的效率要高于赋值运算
    int newCapacity = oldCapacity << 1;
    E[] newElements = (E[]) new Object[newCapacity];
    for (int i = 0; i < size; i++) {
      newElements[i] = elements[i];
    }
    elements = newElements;
	
    System.out.println(oldCapacity + "扩容为" + newCapacity);
  }
	
  private void outOfBounds(int index) {
    // 抛出异常:throw new
    // IndexOutOfBoundsException:拼接字符串信息
    throw new IndexOutOfBoundsException("Index:" + index + ", Size:" + size);
  }
	
  private void rangeCheck(int index) {
    if (index < 0 || index >= size) {
      outOfBounds(index);
    }
  }
	
  private void rangeCheckForAdd(int index) {
    if (index < 0 || index > size) {
      outOfBounds(index);
    }
  }
  
  // 扩容
  private void trim() {
    int oldCapacity = elements.length;
    int newCapacity = oldCapacity >> 1;

    // 如果剩余元素数量大于容量的一半,或者剩余元素数量已经少于默认容量大小,就不需要缩容
    if (size > (newCapacity) || oldCapacity <= DEFAULT_CAPACITY) return;
	
    // 剩余空间还很多
    E[] newElements = (E[]) new Object[newCapacity];
    for (int i = 0; i < size; i++) {
      newElements[i] = elements[i];
    }
    elements = newElements;
	
    System.out.println(oldCapacity + "缩容为" + newCapacity);
  }
  
  @Override
  public String toString() {
    StringBuilder string = new StringBuilder();
    string.append("size=").append(size).append(", [");
    for (int i = 0; i < size; i++) {
      if (i != 0) {
        string.append(", ");
      }
	
      string.append(elements[i]);
    }
    string.append("]");
    return string.toString();
  }
}

设计点的详细讲解:

1.初始化数组时可以设定开辟的存储空间大小,只有大于默认存储空间大小才会使用传进来的值

private static final int DEFAULT_CAPACITY = 10;

public ArrayList(int capaticy) {
  capaticy = (capaticy < DEFAULT_CAPACITY) ? DEFAULT_CAPACITY : capaticy;
  elements = (E[]) new Object[capaticy]; 
}

public ArrayList() {
  this(DEFAULT_CAPACITY);
}

2.利用泛型来接收任意类型的元素

由于所有的类,最终都继承java.lang.Object,所以统一用基类Object初始化数组

public class ArrayList<E> {

  private E[] elements;

  public ArrayList(int capaticy) {
    capaticy = (capaticy < DEFAULT_CAPACITY) ? DEFAULT_CAPACITY : capaticy;
    elements = (E[]) new Object[capaticy]; 
  }
}

3.在Java中,成员变量会自动初始化

int类型自动初始化为0对象类型自动初始化为null

public class ArrayList<E> {
  private int size;
  private E[] elements;
}

4.添加元素时,会先判断存储容量是否已满,如果满了需要扩容

扩容的本质就是创建新的空间,并把之前的元素重新放进去

扩容的容量为原始容量的1.5倍,采用位运算来计算新的容量,效率高于赋值运算

public void add(int index, E element) {
  rangeCheckForAdd(index);
	
  ensureCapacity(size + 1);
	
  for (int i = size; i > index; i--) {
    elements[i] = elements[i - 1];
  }
  elements[index] = element;
  size++;
}

private void ensureCapacity(int capacity) {
  int oldCapacity = elements.length;
  if (oldCapacity >= capacity) return;
	
  // 新容量为旧容量的1.5倍
  // 位运算的效率要高于赋值运算
  int newCapacity = oldCapacity + (oldCapacity >> 1);
  E[] newElements = (E[]) new Object[newCapacity];
  for (int i = 0; i < size; i++) {
    newElements[i] = elements[i];
  }
  elements = newElements;
	
  System.out.println(oldCapacity + "扩容为" + newCapacity);
}

5.删除元素时,先将容量减少,被删除元素的下一个元素将其覆盖,然后将最后一位元素置空

public E remove(int index) {
  rangeCheck(index);
	
  E old = elements[index];
  for (int i = index + 1; i < size; i++) {
    elements[i - 1] = elements[i];
  }

  elements[--size] = null;
  return old;
}

6.清空所有元素时,如果只是值类型,直接将size为0就代表不能访问元素的位置了,而不必要真的将所有元素都置为null

但是元素为对象类型时,元素内存储的只是对象在堆空间的地址值,而不被使用的元素会占据着堆空间不会释放,只有重新添加元素时才会释放对应位置的旧的对象引用

所以为了兼容不同类型的元素,我们要在清空数组的时候将所有元素都置为null,然后再将size置为0

如果数组过长可以考虑通过重新创建对象来缩容

public void clear() {
  for (int i = 0; i < size; i++) {
    elements[i] = null;
  }
  size = 0;
  
  if (elements != null && elements.length > DEFAULT_CAPACITY) {
    elements = (E[]) new Object[DEFAULT_CAPACITY];
  }
}

7.查看元素的索引,要分开判断元素是否为null的条件

如果元素为null,那么要用数组中的每一个元素都去判断是不是空值,而且不可以用Object.equals来判断,会报错

如果元素不为null,就可以直接用作为参数的元素和数组中的每个元素进行判断

Java的Object.equals可以重写来设定判断的条件,不局限于值相等或者地址相等,如果只是用==来判断,相对单一了

public int indexOf(E element) {
  if (element == null) {  	
    for (int i = 0; i < size; i++) {
      if (elements[i] == null) return i;
    }
  } else {
    for (int i = 0; i < size; i++) {
      if (element.equals(elements[i])) return i; // n
    }
  }
  return ELEMENT_NOT_FOUND;
}

复杂度

下面我们就来分析下动态数组中的增删改查的复杂度

get函数的复杂度为O(1)

数组元素的获取本质是通过(index * 每个元素所占字节大小) + 数组地址来实现的,而数组规模也就是执行一次

public E get(int index) {
  rangeCheck(index);
  return elements[index];
}

set函数的复杂度为O(1)

查找方式和get函数相同

public E set(int index, E element) {
  rangeCheck(index);
    
  E old = elements[index];
  elements[index] = element;
  return old;
}

add函数的三种复杂度:

  • 最好复杂度为O(1)
  • 最坏复杂度为O(n)
  • 平均复杂度为O(n)

最好复杂度:需要添加的元素刚好是数组的最后一个位置,那么不需要移动任何元素

最坏复杂度:需要添加的元素是数组的首位,那么需要遍历所有的元素来进行移动

平均复杂度:(1+2+3+...+n)/ n = n/2 所以还是是O(n)

public void add(int index, E element) {
  rangeCheckForAdd(index);
    
  ensureCapacity(size + 1);
    
  for (int i = size; i > index; i--) {
    elements[i] = elements[i - 1];
  }
  elements[index] = element;
  size++;
}

remove函数的三种复杂度:

  • 最好复杂度为O(1)
  • 最坏复杂度为O(n)
  • 平均复杂度为O(n)

最好复杂度:需要移除的元素刚好是数组的最后一个位置,那么不需要移动任何元素

最坏复杂度:需要移除的元素是数组的首位,那么需要遍历所有的元素来进行移动

平均复杂度:同add函数的平均复杂度

public E remove(int index) {
  rangeCheck(index);
    
  E old = elements[index];
  for (int i = index + 1; i < size; i++) {
    elements[i - 1] = elements[i];
  }

  elements[--size] = null;
  return old;
}

添加元素到尾部的函数的三种复杂度:

  • 最好复杂度为O(1)
  • 最坏复杂度为O(n)
  • 平均复杂度为O(1)

最坏复杂度要考虑如果正好数组容量满了,那么还需要扩容才可以,扩容又需要将所有元素进行遍历添加到新数组中,那么复杂度就是O(n)

平均复杂度因为大多数时候的复杂度都是O(1),只有扩容的时候会改变复杂度,而扩容也是少数情况,所以平均复杂度是O(1)

public void add(E element) {
  add(size, element);
}

均摊复杂度

复杂度中还有一个概念叫均摊复杂度,例如上面添加元素到数组最后位置的函数,每次添加的元素的复杂度为O(1),只有到达最大容量了才会进行扩容,而均摊复杂度的意思即是将扩容的操作次数平均分摊到前面每一次的添加操作中,即每次添加的操作为2,也还是O(1)复杂度,所以均摊复杂度也为O(1)

一般均摊复杂度都是和最好复杂度是一致的

-w788

什么情况下适合使用均摊复杂度

经过连续的多次复杂度比较低的情况后,出现个别复杂度比较高的情况

复杂度震荡

如果在扩容的时机频繁的添加元素和删除元素,那么会造成频繁的扩容和缩容,这种情况就叫做复杂度震荡

避免复杂度震荡的办法是要区分开扩容和缩容的倍数不要为1