引言
上一部分,我们了解了数据结构的基本类型和基础概念,下面就开始分享一些我们常用的数据结构和实现原理。
线性表
线性表是n个类型相同数据元素的有限序列,通常记作(a 0 , a 1 , …a i-1 , a i , a i+1 …,a n-1 ),特点如下:
- 相同数据类型:在线性表中的定义下,我们可以看到从a0到an-1都是相同属性的元素,比如全是数字、字母,复杂一点的话可以代表学生资料、商品信息等等,相同数据类型意思在内存中存储时,每个元素占用相同的内存空间,便于后续的查询定位。
- 顺序性: 在线性表中相邻的元素存在序偶关系,比如说ai-1是ai在直接前驱,ai是ai-1的直接后继。唯一没有直接前驱的元素就是表头,唯一没有直接后继的就是表尾,除了表头和表尾的任何一个元素有且只有一个直接前驱和一个直接后继。
- 有限: 线性表中的元素个数n就是该表的长度,当n=0的时候该表为空表,当一个具有n>0的数据元素的线性表,它的序号范围是[0, n-1]。
线性表的存储结构:
-
顺序表—顺序存储结构:
特点: 在内存中分配连续的存储空间,只存储数据,隐式存储每个元素的内存地址。
优点:
1.节省存储空间,因为分配的内存空间只需要存储数据,存储的数据不需要额外的空间存储地址信息,所以节点之间的逻辑关系没有占用额外的内存空间。
2.索引查找效率高,因为分配的是一块连续的内存空间,即每个节点对应一个序号(索引), 由这个序号可以直接算出这个节点的存储地址。假设线性表每个元素需要K个存储单元,并以元素所占的第一个存储单元作为该元素的存储地址。则线性表序号为i的数据元素的存储地址LOC(ai)与序号为i+1的存储地址LOC(ai+1)之间的关系为:
LOC(ai+1) = LOC(ai) + k
则序号为0和i的数据元素的存储地址关系为:
LOC(ai) = LOC(a0) + i * k缺点:
1.插入和删除操作需要移动元素,效率较低。
2.必须提前分配固定数量的空间,如果存储元素少,可能导致空闲浪费。
3.按照内容查询效率低,因为需要逐个比较判断这里面的优缺点还是要记一下,因为在后面比较各种数据结构的时候比较好理解。
栗子:
长度为n的数组中删除元素,假设每个元素删除的概率是相同的,问时间复杂度是?
删掉第n个元素,需要移动0个元素
删掉第n-1个元素,需要移动1个元素
删掉第n-2个元素,需要移动2个元素
…
删掉第2个元素,需要移动n-2个元素
删掉第1个元素,需要移动n-1个元素
所以平均时间频度是:0 * 1/n+ 1 * 1/n + 2 * 1/n + 3 * 1/n + … + (n-1) * 1/n = (n-1) * n/2 * 1/n = (n-1)/2
这个公式还是有必要说一下,有的童鞋可能忘记了时间频度的计算, 在移动元素的时候,最坏的情况下需要移动n-1次,因为删除第n个元素不需要移动元素。 那我移动一次的时间频度就是1/n, 移动两次就是2 * 1 / n也就是2 / n,以此类推。。。。 至于它的删除操作,后面用代码详解。
T(n) = (n-1)/2
T(n)= O(n)
-
链表—链式存储结构:
特点:
数据元素的存储对应的是不连续的存储空间,每个存储结点对应一个需要存储的数据元素。 每个结点是由数据域和指针域组成。 元素之间的逻辑关系通过存储节点之间的链接关系反映出来。逻辑上相邻的节点物理上不必相邻。缺点:
1、比顺序存储结构的存储密度小 (每个节点都由数据域和指针域组成,所以相同空间内假设全存满的话顺序比链式存储更多)。
2、查找结点时链式存储要比顺序存储慢(每个节点地址不连续、无规律,导致按照索引查询效率低下)。
优点:
1、插入、删除灵活 (不必移动节点,只要改变节点中的指针,但是需要先定位到元素上)。
2、有元素才会分配结点空间,不会有闲置的结点。这里的话,大家还是主要分清线性表和顺序表、链表之间的关系,顺序表和链表的区别是很关键的,在后面的学习中很重要。
顺序表
上面介绍了线性表的两种存储结构,这里简单的实现一下顺序表的代码,其实也就是ArrayList的源码。
public class ArrayList implements List{
private Object [] elementData; //底层数组来存储多个元素
private int size;//存储的元素的个数,线性表的长度,注意,不是数组的长度
public ArrayList(){
//按照指定的长度给数组分配空间
this(2);
//this.elementData = new Object[]{};
//this.elementData = new Object[0];
}
public ArrayList(int initialCapacity){
//如果initialCapacity<0,抛出异常
if(initialCapacity<0){
throw new RuntimeException("初始长度要大于0:"+initialCapacity);
}
//按照初始长度给数组分配空间
elementData = new Object[initialCapacity];
//指定线性表初始元素个数,可以省略,int成员变量默认值0
//this.size = 0;
}
@Override
public int size() {
return size;
}
@Override
public Object get(int i) {
//对i的值进行判断
if(i>=size){
//throw new RuntimeException("数组指针越界异常:"+i);
throw new IndexOutOfBoundsException("数组指针越界异常:"+i);
}
return elementData[i];
}
@Override
public boolean isEmpty() {
return size ==0;
}
@Override
public boolean contains(Object e) {
//逐个判断各个元素是否与要查询元素内容相同,效率低下
//注意不是elementData.length,而是size
// for(int i =0;i<size;i++){
// if(e.equals(elementData[i])){
// return true;
// }
// }
// return false;
return this.indexOf(e)>=0;
}
@Override
public int indexOf(Object e) {
//逐个判断各个元素是否与要查询元素内容相同,效率低下
//注意不是elementData.length,而是size
//有漏洞,如果e是null呢?使用if分别处理
for(int i =0;i<size;i++){
if(e.equals(elementData[i])){
return i;
}
}
return -1;
}
@Override
public void add(int i, Object e) {
//如果i超过了size,抛出异常
if(i>size || i< 0){
//throw new RuntimeException("数组指针越界异常:"+i);
throw new IndexOutOfBoundsException("数组指针越界异常:"+i);
}
//需要先判断length是否足够,如果已经满,需要扩容
if(size==this.elementData.length){//满了
grow();
}
//添加元素
//从后向前后移后面元素一个位置
for(int j= size ; j>i; j--){
this.elementData[j] = this.elementData[j-1];
}
//添加元素到指定位置
this.elementData[i] = e;
//元素个数+1
this.size ++;
}
@Override
public void add(Object e) {
//需要先判断length是否足够,如果已经满,需要扩容
if(size==this.elementData.length){//满了
grow();
}
//增加元素
this.elementData[size] = e;
//元素长度+1
size++;
//可以合并为一条语句
//this.elementData[size++] = e;
}
public void grow(){
// //创建一个新的更长数组:策略,增长多少? 100% 50%
// Object [] newArr = new Object [this.elementData.length *2];
// //将旧数组数据放入新数组
// for(int i=0;i<this.elementData.length;i++){
// newArr[i] = this.elementData[i];
//
// }
// //旧数组引用指向新数组
// this.elementData = newArr;
// //下面的方法是源码写的,一种更简便的方法,化繁为简还是要先了解“繁“
elementData = Arrays.copyOf(elementData, this.elementData.length *2);
}
@Override
public boolean addBefore(Object obj, Object e) {
return false;
}
@Override
public boolean addAfter(Object obj, Object e) {
return false;
}
@Override
public Object remove(int i) {
return null;
}
@Override
public boolean remove(Object e) {
return false;
}
@Override
public Object replace(int i, Object e) {
return null;
}
@Override
public String toString() {
if(this.size ==0){
return "[ ]";
}
StringBuilder builder = new StringBuilder();
builder.append('[');
for(int i =0;i<size;i++){
if(i != size -1){
builder.append( this.get(i)+",");
}else{
builder.append( this.get(i));
}
}
builder.append("]");
return builder.toString();
}
}
}
有些方法并没有补全,其实只要自己写上了一两个其他的并不是很难,主要是要学习它的设计逻辑和思想。有兴趣的话可以自己把剩下的补全,自定义异常(有点懒,没有上代码,就是写一个类继承Exception然后写几个构造方法让它返回你想要的那个样子。。。),有兴趣自己写一个main自己测试一下吧^_ ^
单链表
接下来就是实现单链表了,通过之前的学习我们知道,链表是线性表的一种存储结构,它是由一系列存储数据元素的单元通过指针串联起来的,所以每个存储数据元素的单元至少有两个域,一个是存储数据元素,一个存储指向其他单元的指针。
这里具有一个数据域和多个指针域的存储单元通常称为结点(node)。如下图所示:
这是它是构成单链表的最基本结点结构,在图中数据域用来存储数据元素,指针域用于指向下一个相同结构的结点。因为只有一个指针域所以称为单链表,有两个指针域就称为双向链表,双向链表在下面会给介绍。
-
单链表的第一个结点和最后一个节点称为首结点和尾结点。
-
从上图我们可以知道尾结点的next引用为空(null),链表中的每一个next都相当于一个指针,指向另一个结点,借助这些指针,我们可以从链表的首结点一直移动到尾结点。
-
在以后了解复杂的数据结构中这些概念就显得十分重要,在单链表中通常使用head(通过一些其他的技术博文,对于这个head有很多的名称,不用太纠结这个名称问题)引用指向链表的首结点,通过head结点我们可以访问整条链表的任何一个节点。
现在我们了解了单链表的基本结构,让我们开心的来写代码吧,用代码来完成它~~~
第一步:
我们知道了单链表是由一个一个结点构成的,所以我们首先定义一个结点类。
public class Node{
//这里的Data就是上面说的数据域
private Object Data;
//这里的next就是上面说的指针域
private Node next;
public Node(){
super();
}
public Node(Object data){
super();
this.data = data;
}
public Node(Object data, Node next){
super();
this.data = data;
this.next = next;
}
public Object getData() {
return data;
}
public void setData(Object data) {
this.data = data;
}
public Node getNext() {
return next;
}
public void setNext(Node next) {
this.next = next;
}
}
第二步:
接下来就是单链表的逻辑运算了,增删改查。。。。在咱们写代码之前,我们先理一下思路,在单链表中要是实现增删改查它的逻辑是怎样的呢?好吧。。。。往下看
加一个红色的框框吧,标记一下重点-_-# 通过上面的单链表结构图中,我们可以看到数据域和指针域,数据域中是存放数据元素的地方,但是在我们定义结点类的时候我们的数据域是一 个Object对象,并不是一个实际的数据元素,所以每个数据元素并不是像图中那样而是在数据域中通过一个Object类的对象引用来指向数据元 素的。 单链表和数组类似,单链表的结点也具有一个线性次序,结点P的next引用指向结点S,则P是S的直接前驱,S是P的直接后续。 单链表的一个特性就是可以通过前驱节点找到后续结点,不能通过后续结点找到前驱结点。
增删改查想结合代码一起说,概念比较抽象,让人更好理解一点吧。
/*
单链表的实现
可以看到也是实现了List接口方法
*/
public class SingleLinkedList implements List{
//声明一个头结点实例,有且只有一个头结点
public Node head = new Node();
//默认长度是0,头结点不算
public int size;
public SingleLinkedList(){
}
@Override
public int size() {
return size;
}
/**
* 这里就和数组有些不一样了哦~~~
*/
@Override
public Object get(int i) {
//判断i的范围,提高健壮性
if(i<0 || i>size){
throw new IndexOutOfBoundsException("索引越界异常:"+i);
}
if(i < size){
Node p = head.getNext();
for(int x =0; x<i; x++){
p = p.next;
}
return p;
}
}
@Override
public boolean isEmpty() {
return size == 0;
}
@Override
public boolean contains(Object e) {
return this.indexOf(e)>=0;
}
/*
* 查询操作
* 从下面的代码可以看到,单链表的查询操作只能从首结点开始,通过的每个结点的next引用来访问链表的每一个结点
* 来达到查询的目的.
* 比如你要在单链表中查询是否包含某个数据元素e,则方法就是使用一个循环变量P,起始时从单链表的头结点开始,
* 每次循环判断p所指向的结点的数据域是否与e相同,如果相同则返回true,如果不相同则返回下一个结点,直到循环完所有的结点
* 当结点全被访问,p为null
* 缺点: 逐个比较,频繁移动指针,效率偏低
* 注意: 如果是查询索引为i的元素值,也只能从头结点开始逐个移动到索引为i的位置,效率同样低下。
*/
@Override
public int indexOf(Object e) {
Node p = head.getNext();
for(int i = 0 ;i<size;i++){
//取出当前结点的值
Object data = p.getData();
//判断是否相同
if(e.equals(data)){
return i;
}
//移动指针
p = p.getNext();
}
return -1;
}
/*
* 添加操作:
* 在单链表中数据元素的插入,是通过在链表中插入数据元素所属的结点来完成的。
* 对于链表的不同位置,插入的过程有一些差别
* 中间、末尾的添加过程是一样的,关键是在首部添加会有不同,会改变整个单链表的起始结点
* 以添加中间结点为例:
* 1.指明新节点的后继:s.setNext(p.getNext) 或者 s.next = p.next
* 2.指明新节点的前驱(其实是指明前驱节点的后继是新节点):p.setNext(s) 或者p.next = s
* 添加节点不需要移动数据元素,只需要移动指针,效率高
* 如果是先查询添加位置,再添加新元素,因为有逐个查询的过程,效率就不高了
*/
@Override
public void add(int i, Object e) {
//判断i的范围,提高健壮性
if(i<0 || i>size){
throw new IndexOutOfBoundsException("索引越界异常:"+i);
}
//定位到前一个结点
Node p = head;
for(int j = 0 ;j<i;j++){
p = p.getNext();
}
//创建一个新结点
Node s = new Node();
s.setData(e);
//完成添加操作
s.setNext(p.getNext());//指明新结点的后继
p.setNext(s);//指明新结点的前驱(其实是指明前驱结点的后继是新结点)
//size增1
size++;
}
@Override
public void add(Object e) {
int i = this.size;
this.add(i,e);
}
@Override
public boolean addBefore(Object obj, Object e) {
return false;
}
@Override
public boolean addAfter(Object obj, Object e) {
return false;
}
@Override
public Object remove(int i) {
return null;
}
/*
* 删除操作其实和添加操作差不多是一样的
*/
@Override
public boolean remove(Object e) {
//先确定前驱结点和要删除结点
Node p = head;
Node s = head.getNext();
boolean flag = false;//默认该结点不存在
while(s!= null ){
//判断是否找到
if(e.equals(s.getData())){
flag = true;
break;
}
//如果没有找到,移动指针到后一个结点
p = s;
s = s.getNext();
}
//如果找到,就删除
if(flag){
p.setNext(s.getNext());
s.setNext(null);
s =null;
}
return flag;
}
@Override
public Object replace(int i, Object e) {
return null;
}
@Override
public String toString() {
StringBuilder builder = new StringBuilder("[");
Node p = head.getNext();
while(p!=null){
//取出结点值
Object data = p.getData();
//加入StringBuffer
builder.append(data+",");
//后移一个结点
p = p.getNext();
}
//删除最后的一个逗号
if(builder.length()>1){
builder.deleteCharAt(builder.length()-1);
}
builder.append("]");
return builder.toString();
}
}
在使用单链表实现线性表的时候,为了使程序更加简洁,我们通常在单链表的最前面添加一个哑元结点(哑元的意思是能不能从头节点遍历, 能则不是哑元),也称为头结点。 在头结点中不存储任何实质的数据对象,其 next 域指向线性表中 0 号元素所在的结点, 可以对空表、非空表的情况以及对首元结点进行统一处理,编程更方便,常用头结点。
带头节点的单链表的结构图如下所示:
双向链表
好吧,来到了双向链表。我们了解了单链表,顾名思义双向链表相对于单链表来说多了一个指针域,见图说话:
-
单链表的一个优点是结构简单,但是它也有一个缺点,即在单链表中只能通过一个结点的引用访问其后续结点,而无法访问其前驱结点,要在单链表中找到某个结点的前驱结点,必须从链表的首结点依次向后查找,但是其时间复杂度为O(n)。
-
为此我们可以拓展单链表的结点结构,使得通过一个结点的引用,不但可以访问其后续结点,也可以访问其前驱结点。
如何扩展的单链表结点结构如上图所示,在单链表结构的结点增加一个指针域用户指向前驱结点。 -
通过图我们可以看出,结点是使用pre及next域依次串联在一起而形成的。
-
双向链表和单链表的增删改查逻辑是差不多的,只有在查询操作中双向链表不仅可以从头结点开始也可以从尾结点开始,但是和需要的查询时间是和单链表一样的。
-
在使用双向链表实现链表时,为了使编程更加简洁,我们可以使用带两个哑元结点的链表来实现双向链表。
头结点的prev为null,尾结点的next为null -
在具有头尾结点的双向链表中插入和删除结点,无论插入和删除的结点位置在何处,因为首尾结点的存在,插入、删除操作都可以被归结为某个中间结点的插入和删除;并且因为首尾结点的存在,整个链表永远不会为空,因此在插入和删除结点之后,也不用考虑链表由空变为非空或由非空变为空的情况下 head 和 tail 的指向问题; 从而简化了程序。结合下图体会一下:
代码我就不贴了,为什么呢。因为我在写单链表的时候,看了一下单链表LinkedList的源码,发现它就是双向链表。。。。多少也给了一些启发,源码在查询操作LinkedList中做了一点优化,分享一下:
/**
* Returns the (non-null) Node at the specified element index.
* 这个是LinkedList源码,虽然我们一直在使用前人的轮子
* 其实逻辑是非常简单
* 感叹一下,死读书 读死书 读书死。。。。。
*/
Node<E> node(int index) {
// assert isElementIndex(index);
if (index < (size >> 1)) {
Node<E> x = first;
for (int i = 0; i < index; i++)
x = x.next;
return x;
} else {
Node<E> x = last;
for (int i = size - 1; i > index; i--)
x = x.prev;
return x;
}
}
想想还有栈、队列、图、树、哈希表。。。。emmmmmmmmmmm
限于篇幅限制,暂时就分享这些。本博客文章皆出于学习目的,个人总结或摘抄整理自网络。引用参考部分在文章中都有原文链接,如疏忽未给出请联系本人。另外,作为一名还在处于摸索的攻城狮,如文章内容有错误,欢迎各方大神指导交流。