在学习数组篇之前,我们为什么先问一个问题,我们为什么要学习数据结构。当学习一个东西之前,通常要搞懂是什么?有什么用?怎么用?
数据结构研究的是数据如何在计算机中进行组织和存储,使得我们可以高效的获取数据或者修改数据。为了在不同的应用场景可以高效的获取数据或者修改数据,就发明出了不同的数据结构。
数据结构总体可以分为三种类型
1、线性结构
- 数组;
- 栈;
- 队列;
- 链表;
- 哈希表;
- …
2、树结构
- 二叉树;
- 堆;
- 二分搜索树;
- AVL;
- 红黑树;
- Treap;
- Splay;
- Trie(前缀树);
- 线段树;
- K-D树;
- 并查集;
- 哈夫曼树;
- …
3、图结构
- 邻接矩阵;
- 邻接表;
我们需要根据应用场景的不同,灵活地选择最合适的数据结构。存储我们的数据。同时,数据结构是算法的基石。
可以说,程序 = 数据结构 + 算法;
数组最大的优点:快速查询
数组最好应用于“索引有语义”的情况,但并非所有有语义的索引都适用于数组,如索引本身是较大或复杂的。当然数组也可以处理“索引没有语义”的情况。
自己封装一个数组类,实现一下动态数组的效果,有简单的增删改查的功能。
/**
* @author ymn
* @version 1.0
* @date 2020\4\3 0003 14:27
*/
public class Array<T> {
private T[] data;
//数组实际存放的数据大小
private int size;
/**
* 构造函数,传入数组的容量capacity构造Array
* @param capacity
*/
public Array(int capacity){
data=(T[])new Object[capacity];
size=0;
}
/**
* 无参数的构造函数,默认数组的容量capacity为10
*/
public Array(){
this(10);
}
/**
* 获取数组中的元素个数
* @return
*/
public int getSize(){
return size;
}
/**
* 获取数组的容量
* @return
*/
public int getCapacity(){
return data.length;
}
/**
* 返回数组是否为空
* @return
*/
public boolean isEmpty(){
return size==0;
}
/**
* 向所有元素后添加一个新元素
* @param e
*/
public void addLast(T e){
add(size,e);
}
/**
* 向所有元素前添加一个新元素
* @param e
*/
public void addFirst(T e){
add(0,e);
}
/**
* 在第index位置插入一个新元素e
* @param index
* @param e
*/
public void add(int index,T e){
//判断数组是否已达到最大容量
// if (size==data.length) {
// throw new IllegalArgumentException("addLast failed,array is full");
// }
//判断index的合法性
if(index < 0||index>size){
throw new IllegalArgumentException("addLast failed,Require index>=0 and index<=size");
}
//当数组达到最大容量时,自动扩展2倍容量
if (size==data.length) {
resize(2 * data.length);
}
for (int i = size-1;i >= index;i--){
data[i+1] = data[i];
}
data[index] = e;
size++;
}
/**
* 获取index索引位置的元素
* @param index
* @return
*/
public T get(int index){
//判断index参数的合法性
if (index<0 || index>=size){
throw new IllegalArgumentException("Get Failed,Index is illegal");
}
return data[index];
}
/**
* 修改index索引位置的元素为e
* @param index
* @param e
*/
public void set(int index,T e){
//判断index参数的合法性
if (index<0 || index>=size){
throw new IllegalArgumentException("Get Failed,Index is illegal");
}
data[index] = e;
}
/**
* 查找数组中是否有元素e
* @param e
* @return
*/
public boolean contains(T e){
for (int i=0;i<size;i++){
if (data[i].equals(e)){
return true;
}
}
return false;
}
/**
* 查找数组中元素e所在的索引,如果不存在元素e,则返回-1
* @param e
* @return
*/
public int find(T e){
for (int i=0;i<size;i++){
if (data[i].equals(e)){
return i;
}
}
return -1;
}
/**
* 从数组中删除index位置的元素,返回删除的元素
* @param index
* @return
*/
public T remove(int index){
//判断index参数的合法性
if (index<0 || index>=size){
throw new IllegalArgumentException("Remove Failed,Index is illegal");
}
T ret = data[index];
for (int i=index + 1;i<size;i++){
data[i-1] = data[i];
}
size--;
data[size] = null;
//当数组中有一半空间被浪费的时候,缩减capacity
if (size == data.length / 2){
resize(data.length / 2);
}
return ret;
}
/**
* 从数组中删除第一个元素,返回删除的元素
* @return
*/
public T removeFirst(){
return remove(0);
}
/**
* 从数组中删除最后一个元素,返回删除的元素
* @return
*/
public T removeLast(){
return remove(size-1);
}
/**
* 从数组中删除指定元素e,因为数组中可以放重复元素,所以当数组中有重复元素时,每次只能一个
* @param e
*/
public void removeElement(T e){
int index = find(e);
if (index != -1){
remove(index);
}
}
/**
* 实现动态数组的功能
* @param newCapacity
*/
private void resize(int newCapacity){
T[] newData = (T[])new Object[newCapacity];
for (int i=0;i<size;i++){
newData[i] = data[i];
}
data = newData;
}
@Override
public String toString() {
StringBuilder res = new StringBuilder();
//%d: 占位符; \n: 换行符
res.append(String.format("Array: size = %d, capacity = %d\n",size,data.length));
res.append("[");
for (int i=0;i<size;i++){
res.append(data[i]);
if (i!=size-1)
res.append(",");
}
res.append("]");
return res.toString();
}
}
简单的时间复杂度分析:
- O(1),O(n),O(lgn),O(nlgn),O(n^2)
- 大O描述的是算法的运行时间和输入数据之间的关系
public static int sum(int[] nums){
int sum=0;
for (int num: nums){
sum += num;
}
return sum;
}
通常情况下我们称上面的这个算法是O(n)的,在这个算法中,n是nums中的元素个数,算法的运行时间和n呈线性关系。实际上这个算法的实际运行时间为T=c1*n+c2,但是我们一般选择忽略常数,就称这个算法的时间复杂度是O(n)的。O为渐进时间复杂度,描述n趋近于无穷的情况,一般来说,O(n)的时间复杂度是远小于O(n^2)的,但是如果考虑常数的情况,当n较小时,
O(n^2)的时间复杂度是有可能小于o(n)的。
分析一下上面的那个数组类的一些方法的时间复杂度:
添加操作:总的来说是O(n)的
addLast(e): O(1) ,O(1) 表示这个方法的运行时间和数据规模是没有关系的,也就是说不管当前数组中有多少元素,addLast方法都能在常数时间内完成。
addFirst(e): O(n)
add(index,e): 严格计算需要一些概率论知识O(n/2) = O(n)
resize: O(n)
删除操作:也是O(n)的
removeLast: O(1)
removeFirst: O(n)
remove(index): O(n)
resize : O(n)
修改操作: 已知索引O(1),未知索引O(n)
set(index,e) : O(1)
查找操作:已知索引O(1),未知索引O(n)
get(index) : O(1)
contains(e) : O(n)
find(e) : O(n)
均摊复杂度和防止复杂度的震荡:
均摊复杂度:
写一个复杂度较高的算法,这个高复杂度的算法是为了方便其他操作。此时我们通常会将这个复杂度较高的算法和其他的操作放在一起来分析复杂度。这个复杂度较高的算法复杂度将会均摊到其他的操作中。这种复杂度分析法我们就叫做均摊复杂度分析法。
我们说计算一个算法的时间复杂度要考虑它最坏的(时间最长)情况,但不是每一次都是最坏的情况。就像执行addLast方法不一定会执行resize方法,如果把移动一次元素称为一个基本操作,我们可以认为执行一次addLast方法均摊后会执行2次基本操作,这样的话,也可以认为addLast和removeLast的时间复杂的为O(1)的。
防止复杂的震荡:
如果每次执行addLast和removeLast,正好在扩容和缩容的时候,即每次执行addLast和removeLast都要执行resize方法,这种情况是得本来O(1)的算法变成了O(n)的,这种情况是非常消耗性能和时间的,我们称这种的场景为复杂度的的震荡。
在这个场景中,我们通过在删除元素后不要太激进(Eager)的去释放资源,而是懒惰(Lazy)一点,当删除的元素只有容量(capacity)1/4的时候才缩容
/**
* 从数组中删除index位置的元素,返回删除的元素
* @param index
* @return
*/
public T remove(int index){
//判断index参数的合法性
if (index<0 || index>=size){
throw new IllegalArgumentException("Remove Failed,Index is illegal");
}
T ret = data[index];
for (int i=index + 1;i<size;i++){
data[i-1] = data[i];
}
size--;
data[size] = null;
//防止复杂度的震荡,当数组的大小只有容量的1/4的时候在进行缩容,并且数组的容量不能缩为0
if (size == data.length / 4 && data.length / 2!=0){
resize(data.length / 2);
}
return ret;
}