I:概念
集合:用于存储无需元素,值不能重复
链表:是一种线性表,但是并不会在物理存储上分配一块连续空间来存储它,而是在每一个节点里维护一个指针(pointer)指向下一个节点。因此这n个节点是离散分配的,彼此通过指针相连,链表的插入和删除操作复杂度为O(1),查询复杂度为O(n),链表中主要介绍3类,单向链表、双向链表、循环链表。
单向链表:每个节点都包含下一个节点的指针,表头为空。
双向链表:每个节点包含两个指针,分别指向前一个和后一个节点。首节点没有前驱节点,尾节点没有后续节点。
循环链表:循环链表是另一种形式的链式存贮结构。它的特点是表中最后一个结点的指针域指向头结点,整个链表形成一个环。
数组:数组是在内存中开辟一段连续的空间,并在此空间存放元素。就像是一排出租屋,有100个房间,从001到100每个房间都有固定编号,通过编号就可以快速找到租房子的人。数组的特点是:元素类型是固定的、长度是固定的、通过角标查询,查询快,增删慢。
II:java对象
常用的java对象如下图红色所示:
集合:HashSet、TreeSet
数组:ArrayList
链表:LinkedList
III:用法&深度解析
1、HashSet:存进去的元素是无序的,
运行结果如下:
上面的运行结果是不是很怪,为什么元素的hashcode方法会被调用?,HashSet的存取特性我们可以从源码深入了解一下:
每次添加一个元素的时候,都会往HashMap中put一个新的元素,
public boolean add(E e) {
return map.put(e, PRESENT)==null;
}
HashMap每put一个元素的时候,如下所示,详细逻辑后期会讲到,我们着重关注标颜色的几行代码:
public V put(K key, V value) {
//给每一个元素生成一个hashcode
return putVal(hash(key), key, value, false, true);
}
static final int hash(Object key) {
int h;
//默认调用元素的hashcode方法生成
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
Node<K,V> e; K k;
//如果算出该元素的存储位置目前已经存在其他的元素了,那么会调用该元素的equals方法与该位置的元素再比较一次。
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
。。。。。。
2、TreeSet:可以用来对元素对象进行排序的,同样,也可以保证元素的唯一。
我们把HashSet切到TreeSet,运行如下代码:
报错如下,原因在于我们需要告诉TreeSet如何进行元素比较,如果不指定,就会抛异常:
OK,我们给user类集成comparable接口,重写compareTo方法:
测试代码调整如下:
结果如下:
迫不及待了,我们继续开始从TreeSet的源码来分析其存取原理:
TreeSet的底层是基于TreeMap实现的,TreeMap我们后期会详细剖析
public TreeSet() {
this(new TreeMap<E,Object>());
}
当我们add一个元素的时候,底层调用TreeMap的put方法,TreeMap是基于红黑树的,具体如下,我们只需要关注红字标识的:
public V put(K key, V value) {
Entry<K,V> t = root;
if (t == null) {
//创建红黑树的根节点
compare(key, key); // type (and possibly null) check
root = new Entry<>(key, value, null);
size = 1;
modCount++;
return null;
}
int cmp;
Entry<K,V> parent;
// split comparator and comparable paths
Comparator<? super K> cpr = comparator;
if (cpr != null) {
do {
parent = t;
//取出compare方法,根据元素的compare方法来判断大小并放入树中
cmp = cpr.compare(key, t.key);
if (cmp < 0)
t = t.left;
else if (cmp > 0)
t = t.right;
else
return t.setValue(value);
} while (t != null);
}
else {
if (key == null)
throw new NullPointerException();
@SuppressWarnings("unchecked")
Comparable<? super K> k = (Comparable<? super K>) key;
do {
parent = t;
cmp = k.compareTo(t.key);
if (cmp < 0)
t = t.left;
else if (cmp > 0)
t = t.right;
else
return t.setValue(value);
} while (t != null);
}
Entry<K,V> e = new Entry<>(key, value, parent);
if (cmp < 0)
parent.left = e;
else
parent.right = e;
fixAfterInsertion(e);
size++;
modCount++;
return null;
}
3、LinkedList
java中的linkedlist是一个继承于AbstractSequentialList的双向链表。它也可以被当做堆栈、队列或双端队列进行操作。
LinkedList 实现 Deque 接口,即能将LinkedList当作双端队列使用。
LinkedList 实现了Cloneable接口,即覆盖了函数clone(),能克隆。
LinkedList 实现java.io.Serializable接口,这意味着LinkedList支持序列化,能通过序列化去传输。
LinkedList 是非同步的
LinkedList的查询很慢、增删快,为什么呢,要从源码开始分析其,废话不多说,我们源码看起来:
public class LinkedList<E>
extends AbstractSequentialList<E>
implements List<E>, Deque<E>, Cloneable, java.io.Serializable
{
transient int size = 0;
/**
* 指针指向链表中的第一个节点.
* Invariant: (first == null && last == null) ||
* (first.prev == null && first.item != null)
*/
transient Node<E> first;
/**
* 指针指向链表中的最后一个节点.
* Invariant: (first == null && last == null) ||
* (last.next == null && last.item != null)
*/
transient Node<E> last;
。。省略中间。。。
/**
* 添加一个元素到队列尾部时调用
*/
void linkLast(E e) {
//取出最后一个节点l
final Node<E> l = last;
//新建一个节点,prev指针指向l节点,item值为当前Node,next指针指向空
final Node<E> newNode = new Node<>(l, e, null);
//赋值为last节点
last = newNode;
if (l == null)
//如果是空队列,则默认为第一个元素
first = newNode;
else
//l的next指针指向当前新建的node
l.next = newNode;
//链表长度加1
size++;
//被修改次数加1
modCount++;
}
/** 链表删除元素 */
public boolean remove(Object o) {
if (o == null) {
for (Node<E> x = first; x != null; x = x.next) {
if (x.item == null) {
//元素为空的时候,也存在移除情况,只不过没用equals方法,而是直接做空判断
unlink(x);
return true;
}
}
} else {
//移除某个元素的时候,需要遍历该链表
for (Node<E> x = first; x != null; x = x.next) {
if (o.equals(x.item)) {
//匹配出来,开始移除链表元素
unlink(x);
return true;
}
}
}
return false;
}
/**
* 链表中移除一个非空的元素.
*/
E unlink(Node<E> x) {
// assert x != null;
//取出元素实体
final E element = x.item;
//取出下一个节点
final Node<E> next = x.next;
//取出上一个节点
final Node<E> prev = x.prev;
if (prev == null) {
//如果当前元素为头元素,则删除后,头元素就是next所指向的
first = next;
} else {
//否则,将上一个元素的next指针指向next所指向的元素
prev.next = next;
//将当前元素的prev指向为空
x.prev = null;
}
if (next == null) {
//如果当前元素为尾巴元素,则删除后,上一个节点变为尾巴元素
last = prev;
} else {
//否则,将当前元素的next指向为空,将下一个元素的prev指向上一个元素
next.prev = prev;
x.next = null;
}
x.item = null;
//链表长度-1
size--;
//被修改次数+1
modCount++;
return element;
}
最后,我们稍微补充复习一下算法的时间复杂度和空间复杂度相关的知识,关于算法复杂度的计算,会贯穿接下来的数据结构和算法章节,故我们先行复习一下,熟悉的同学可以跳过:
在计算机科学中,我们为了衡量一个算法的效率,有不同的度量方法,例如,我们可以通过事后统计的办法,针对算法设计相关的测试程序和数据,利用计算机计时器来对算法的整个运行时间进行度量,从而确定算法效率的高低。但是这种方法还是需要依赖于计算机体系的相关支持,于是,为了快速验证度量算法的效率,我们又提出事前的分析估算方法,即:算法的时间复杂度和空间复杂度。
时间复杂度:
1)时间频度 :一个算法执行所耗费的时间,从理论上是无法计算出来的,必须上机运行才能得知。但我们在实际情况中,没必要知道具体耗费多少,只需要知道哪个算法之间相对花的时间多少就行,并且一个算法话费的时间是和算法中语句的执行次数成正比的,一个算法中语句执行次数称为时间频度,记为T(n)。
2)时间复杂度 : 刚刚的时间频度中,n称为问题的规模,T(n)随着n的变化而变化,如果我们想知道它变化的规律呢??这是,我们引入了时间复杂度的概念,正主出场:一般情况下,当出现一个辅助函数f(n),使得当n趋近无穷大时,T(n)/f(n)的极限值为不等于0的常数,则称f(n)是T(n)的同数量级函数。记为T(n)=O(f(n)),这里的O(f(n)) 为算法的渐进时间复杂度,简称时间复杂度。由上可知, 时间复杂度反映了程序执行时间随输入规模增长而增长的量级,在很大程度上能很好反映出算法的优劣与否。
常见的算法时间复杂度由小到大依次为:Ο(1)<Ο(log2n)<Ο(n)<Ο(nlog2n)<Ο(n2)<Ο(n3)<…<Ο(2n)<Ο(n!)
3)怎么计算时间复杂度:
i:找出算法中的基本语句;算法中执行次数最多的那条语句就是基本预计,通常是最内层循环的循环体。
ii:计算基本语句执行次数的数量级;即,我们只需要保证基本语句执行次数的函数中的最高次幂正确即可,可以忽略所有低次幂和最高次幂的系数。这样能够简化算法分析步骤,且把注意力放到最重要的一点上:增长率 上来。
iii:用大O记号表示算法的时间性能;将基本语句执行次数的数量级放入大O记号中。
iv:相关分析法则;
a:对简单的输入输出语句或赋值语句,近似认为O(1)时间。
b:对于顺序结构,需要依次执行一系列语句所用时间时,可以采用大O下的“求和法则”:
假如算法中存在两个部分T1(n)=O(f(n))和 T2(n)=O(g(n)),则 T1(n)+T2(n)=O(max(f(n), g(n)))
c:对于选择结构,如if语句,它的主要时间耗费是在执行then字句或else字句所用的时间,需注意的是检验条件也需要O(1)时间。
d:对于循环结构,循环语句的运行时间主要体现在多次迭代中执行循环体以及检验循环条件的时间耗费,一般可用大O下"乘法法则":
假如算法中存在两个部分T1(n)=O(f(n))和 T2(n)=O(g(n)),则 T1*T2=O(f(n)*g(n))
e:对于复杂的算法,可以将其分为几部分,然后利用求和法则和乘法法则来计算整个算法的时间复杂度 。
空间复杂度:
概述: 类似时间复杂度,一个算法的空间复杂度(Space Complexity)S(n)定义为该算法所耗费的存储空间,它也是问题规模n的函数。渐进空间复杂度也常常成为空间复杂度。
空间复杂度:算法在执行过程中,临时占用存储空间大小的度量,存储空间包括存储算法本身占用的存储空间、算法输入输出数据、算法运行过程中临时占用的空间这三方面。
如何计算:
1)忽略常数,用O(1)表示
2)递归算法的空间复杂度 = 递归深度N * 每次递归所需辅助空间
3)对单线程来说,递归有运行时堆栈,求的是递归最深的那一次压栈所耗费的空间个数,因为递归最深那一次所耗费的空间足以容纳它所有递归过程。
空间换时间:
我们在写代码时,完全可以用空间来换去时间。
举个例子说,要判断某年是不是闰年,你可能会花一点心思来写一个算法,每给一个年份,就可以通过这个算法计算得到是否闰年的结果。
另外一种方法是,事先建立一个有2050个元素的数组,然后把所有的年份按下标的数字对应,如果是闰年,则此数组元素的值是1,如果不是元素的值则为0。这样,所谓的判断某一年是否为闰年就变成了查找这个数组某一个元素的值的问题。第一种方法相比起第二种来说很明显非常节省空间,但每一次查询都需要经过一系列的计算才能知道是否为闰年。第二种方法虽然需要在内存里存储2050个元素的数组,但是每次查询只需要一次索引判断即可。这就是通过一笔空间上的开销来换取计算时间开销的小技巧。到底哪一种方法好?其实还是要看你用在什么地方。