【日撸 Java 三百行】Day 13(链表)
目录
Day 13:链表
一、关于链表
二、关于链表中的 “链”
1. 从 C/C++ 到 Java,“指针” 有何变迁
2. Java 实现的链表
三、链表的方法
1. 清空
2. 遍历
3. 检索
4. 插入
5. 删除
四、代码及测试
拓展:链表
小结
Day 13:链表
Task:
- 支持与顺序表相同的操作: 初始化、插入、删除等.
- 为节点建一个类.
- 引用与指针的异同. 前者只能使用; 后者可以支持 p ++ 危险操作.
- 引用数据类型的赋值, 都不会产生新的对象空间.
- 链表与线性表在插入、删除时的不同: 前者不移动元素, 只改变引用 (指针).
- 今天的代码稍微多一点, 不过有昨天的铺垫, 还好.
一、关于链表
链表的逻辑结构与物理结构并不像顺序表那样亲密,链表在物理存储上并不是严格连续的,其连续性不由硬件进行担保,而更多依靠的是代码中有关变量进行担保,也就是我们所谓的 “链” 。换言之,其实是用 “指针”(至于为什么打引号,后续会说明)来实现的。
这就像我们的链子一样,一环接着一环,但是这个是我们逻辑上说明的链表形象,但是实际上的物理存储中我们的链子不像这样清晰,具体来说,物理存储中更像你从口袋中拿出的耳机那样:
尽管顺序表是一种非常有用的数据结构,但其至少存在以下两方面的局限:
(1)改变顺序表的大小需要重新创建一个新的顺序表并把原有的数据都复制过去。
(2)因为逻辑关系是通过物理位置的相邻来表示的,增删元素平均需要移动一半的元素
为了克服顺序表无法改变长度的缺点,并满足许多应用经常插人新结点或删除结点的需要,产生了链表(Iinked list)这样的数据结构。
链表可以看成一组既存储数据又存储相互连接信息的结点集合。这样,各结点不必如顺序表那样存放在连续的存储空间中,可以散放在存储空间的各处,而由称为指针的域来按照线性表的后继关系链接结点。链表的特点是可以动态地申请内存空间,根据线性表元素的数目动态地改变其所需要的存储空间。在插人元素时申请新的存储空间,删除元素时释放其占有的存储空间。
二、关于链表中的 “链”
1. 从 C/C++ 到 Java,“指针” 有何变迁
指针是C、C++中最灵活,也是最容易产生错误的数据类型。由指针所进行的内存地址操作常会造成不可预知的错误,同时通过指针对某个内存地址进行显示类型转换后,可以访问一个C++中的私有成员,从而破坏了安全性,造成系统的崩溃。而Java对指针进行完全的控制,程序员不能直接进行任何指针操作,例如,把整数转化为指针,或者通过指针释放某一内存地址等。同时,数组作为类在Java中实现,很好地解决了数组访问越界这一在C/C++中不做检查的错误,
在这篇文章中,有人这么评价指针:
文章参考:(99+ 封私信 / 81 条消息) 关于指针,为什么有的语言没有指针?指针一般应用于什么领域? - 知乎
“每一种语言都使用指针,C/C++只是将它显露在外而非隐藏它”
我们可以发现一个自然的现象,随着语言的级别越高,似乎越来越不喜欢使用指针这个说法了。我们常见的诸多语言中只有C与C++还在坚持使用指针(Pointer)的概念与说法,而后续的再高级语言,例如Python与Java都直接抛弃这个概念,C#也更是婉转避开这个概念,而是通过unsafe来使用(一般情况下使用的是委托),而Java使用的是引用的概念。
但实际上,从某种角度来理解,我们可以认为指针无处不在,Java 中的引用就是一种虚拟指针。从这个思路出发,可能能更容易理解在 Java 中是如何实现链表的。
2. Java 实现的链表
所以,java自然没有指针这个概念,那么如何实现链表呢。
操作中,我们不再使用我们熟悉的星号符来修饰指针(Pointer),而是通过变量的引用(Reference)来完成这个指针的作用,而这个引用的声明,就和我们平常变量声明一样。
正常我们声明一个对象时都要通过new为它分配一些空间,但是如果不用new的话,这个对象就真的没有空间了吗?
其实不然,单独声明一个孤单的对象时,他在内存中也是会单独开辟一个很小的空间(根据操作系统,在4B或8B一定),这个空间内部是用于存放后期使用new为其分配空间后,用于存放分配空间的起始地址的。
这么来看,这个没使用new补充的空间是不是很像C/C++中的“ 野指针 ”?而且细细回想下,我们C/C++的new操作必须要求接收new赋值的变量必须是指针,因为new是返回分配空间的地址。
如此,我们便能够初始化链表。其中,需要包含用于存储结点数据值的 data 参数,和用于指向下一个结点的 next 引用:
/*** An inner class*/class Node {/*** The data*/int data;/*** The reference to the next node*/Node next;/********************* * The constructor* * @param paraValue The data.******************* */public Node(int paraValue) {data = paraValue;next = null;}// Of the constructor}// Of class Node
初始化后,我们便能够编写最基本的构造函数:
/*** The header node. The data is never used.*/Node header;/************************ Construct an empty linked list.**********************/public LinkedList() {header = new Node(0);}// Of the first constructor
三、链表的方法
与顺序表相同,细致的讲解在专题补充 【数据结构】线性表-CSDN博客 中已经讲过了,这里挑出需要理解的部分再次强化。
1. 清空
与顺序表不同,链表只需要清空 header 结点,即可完成。
/************************ Reset to empty. Free the space through garbage collection.**********************/public void reset() {header.next = null;}// Of reset
2. 遍历
链表的遍历是对象的迭代操作,而不像是顺序表的下标递增操作,我们通过 tempNode = tempNode.next; 的操作完成对象的往后迭代的效果,直至 tempNode == null 结束迭代。
具体实现上,先提供基本的健壮性保证(判断若为空表则不再遍历)。第一步是创建工作结点,因为我们遍历只要求访问有效数据区域,因此工作结点是头指针的下个位置 Node tempNode = header.next;
之后通过while操作完成有效区域的迭代并输入到输出字符中即可。难度不是很大。
/************************ Overrides the method claimed in Object, the superclass of any class.**********************/public String toString() {String resultString = "";if (header.next == null) {return "empty";} // Of ifNode tempNode = header.next;while (tempNode != null) {resultString += tempNode.data + ", ";tempNode = tempNode.next;} // Of whilereturn resultString;}// Of toString
3. 检索
链表的检索无非是对遍历算法的引用。因为只要在原来遍历基础上同步更新一个计数器,然后在发现数据时终止数据并且返回计数器即可。
需要注意的是,如何合理的设置下标。
我们的代码定义为头结点不纳入有效计数,那么第一个有效结点就是下标0(全链的第二个结点)。
先设置默认下标值 tempPosition 为-1,之所以设置一个默认非法值是因为在空表情况下,while()循环不会执行,便于直接跳过while直接返回时返回非法值告诫查无此数之作用。
而我们把计数器仅仅放于while()内计数,等到出现 tempNode.data == paraValue 后将计数器交付给tempPosition,退出while(),将由tempPosition交付结果。这个好处在于当全部遍历完毕后但是没有出现满足取等条件后,tempPosition不会知晓计数器,因此还是会返回默认值-1,告诫查无此数。
其中,tempPosition 的值为链表中第几个数据。
/************************ Locate the given value. If it appears in multiple positions, simply return* the first one.* * @param paraValue The given value.* @return The position. -1 for not found.**********************/public int locate(int paraValue) {int tempPosition = -1;Node tempNode = header.next;int tempCurrentPosition = 0;while (tempNode != null) {if (tempNode.data == paraValue) {tempPosition = tempCurrentPosition;break;} // Of iftempNode = tempNode.next;tempCurrentPosition++;} // Of whilereturn tempPosition;}// Of locate
4. 插入
链表的插入思想与顺序表式相同的,只不过实现步骤上有所不一样。
- 找到位置
- 插入
必要准备:
- 知道我要删除结点 n 的值
- 获得结点 n 之前的结点 n-1 的指针
- 创建结点 new,并完成必要的赋值
具体操作:
- 将新结点 new 的next指向我们的目标结点
- 前结点 n-1 的next指针指向我们的新结点 new
- 完成
要注意,操作的流程是不可更换的,其中步骤 2 具有断链的作用,这种操作必须放在最后执行,一旦先执行的话 new 结点将无法找到需要插入的后续结点。
/************************ Insert a value to a position. If the list is already full, do nothing.* * @param paraPosition The given position.* @param paraValue The given value.* @return Success or not.**********************/public boolean insert(int paraPosition, int paraValue) {Node tempNode = header;Node tempNewNode;for (int i = 0; i < paraPosition; i++) {if (tempNode.next == null) {System.out.println("The position " + paraPosition + " is illegal.");return false;} // Of iftempNode = tempNode.next;} // Of for i// Construct a new node.tempNewNode = new Node(paraValue);// Now link them.tempNewNode.next = tempNode.next;tempNode.next = tempNewNode;return true;}// Of insert
补充: 插入的寻位技巧与特殊情况
实际上,这里存在一个寻位的易错点。
for (int i = 0; i < paraPosition; i++) {if (tempNode.next == null) {System.out.println("The position " + paraPosition + " is illegal.");return false;} // Of iftempNode = tempNode.next;} // Of for i
在定位中,由于我们的 i 是从 0 开始的,所以考虑到 for 循环语句的执行特性,paraPosition 的取值是小于等于链表长度length,插入的位置即是原链表中该有的位置。
而对于这种情况,在专题补充中链表的插入操作有更加细致的分类处理,值得借鉴(代码如下):
//插入单链表的第 i 个结点
template <class T> //线性表的元素类型为T
bool lnkList<T> :: insert(const int i, const T value) {Link<T> *p, *q;if((p = setPos(i-1)) == NULL) { //p是第i个结点的前驱cout << "非法插人点" << endl;return false;}q = new Link<T>(value, p->next);p -> next = q;if(p == tail) //插人点在链尾,插人结点成为新的链尾tail = q;return true;
}
5. 删除
删除的操作同样有两个关键:
- 找到位置
- 删除
找到位置这个与插入的方案一样都是找前驱的问题,这里我就不赘述了,我们主要讨论删除的这个操作本身的特点。
必要准备:
- 知道我要删除结点1的值
- 获得结点1之前的结点0的指针
具体操作:
- 将前驱结点0的next指向我们的目标结点1的下一个结点
- 完成
如果是C/C++等的语言的话,这里可能还需要用free()方法释放结点1的空间,但是因为Java内部具有自动释放无主存储空间的功能,因此这里并不需要程序员手动完成释放操作。
需要额外注意的是,删除前指针和删除后指针的操作的复杂度是不同的。考虑到链表的特性,一般而言,我们 currentPos 通常指向当前指针的上一个指针。
四、代码及测试
public static void main(String args[]) {LinkedList tempFirstList = new LinkedList();System.out.println("Initialized, the list is: " + tempFirstList.toString());for (int i = 0; i < 5; i++) {tempFirstList.insert(0, i);} // Of for iSystem.out.println("Inserted, the list is: " + tempFirstList.toString());tempFirstList.insert(5, 100);// tempFirstList.delete(4);// tempFirstList.delete(2);System.out.println("Deleted, the list is: " + tempFirstList.toString());tempFirstList.delete(0);System.out.println("Deleted, the list is: " + tempFirstList.toString());for (int i = 0; i < 5; i++) {tempFirstList.delete(0);System.out.println("Looped delete, the list is: " + tempFirstList.toString());} // Of for i}// Of main
拓展:链表
参考学习笔记:【数据结构】线性表-CSDN博客
小结
在有 C/C++ 打下的指针基础的情况下,链表的实现还是比较好理解的。关于空间分配机制,这里给出闵帆老师的讲解:Java 程序设计基础(附1:空间分配机制)_java地址空间分配表和-CSDN博客,配合图来说,更容易理解。
实际上相比于链表的具体定义,更容易出错的是具体的实现方式上。由于链表使用的引用,所以需要我们时刻去注意非空(这与指针是一样的,空指针会导致程序出较为严重的逻辑错误)。每当我们使用引用时,需要时刻清晰的知道此时引用到底指向哪儿。