本文将使用Java实现链表以及链表的常用操作。
一、什么是链表
链表是一种物理存储单元上非连续、非顺序的存储结构,数据元素的逻辑顺序是通过链表中的指针链接次序实现的。链表由一系列结点(链表中每一个元素称为结点)组成,结点可以在运行时动态生成,而且由于没有闲置的内存,因此空间效率比数组高。其插入操作可达到O(1)复杂度,但是查找或者访问特定的结点复杂度是O(n)。
二、单向链表的元素增加实现
在使用Java实现链表之前需要搞懂,为什么Java可以实现链表。在C/C++中链表的实现是通过指针来实现,那在Java中呢?实际上在Java中除了基本数据类型到处都是指针,比如MyClass mc = new MyClass();实际上mc就相当于指向内存中MyClass对象的指针。因此可以利用这个特性实现链表:
package link;
public class MyLink<E> {
private LinkNode<E> head;
private static class LinkNode<E>{//使用泛型保证程序的可重用性和安全性
E data;
LinkNode<E> next;//指向下一个结点的指针
}
//添加新的结点到尾部
public void add(E data){
LinkNode<E> newNode = new LinkNode<E>();
newNode.data = data;
newNode.next = null;
if(head == null){//之前的链表为空
head = newNode;//新结点即为头结点
}
else{
LinkNode<E> temp = head;
while(temp.next!=null){
temp = temp.next;
}//找到最后一个结点
temp.next = newNode;//最后一个结点指向新结点
}
}
//重写toString 方便打印链表
@Override
public String toString(){
if(head==null)
return "Empty link";
LinkNode<E> temp = head;
StringBuilder sb = new StringBuilder();
while(temp.next!=null){
sb.append(temp.data+" ");
temp = temp.next;
}
sb.append(temp.data);
return sb.toString();
}
}
请特别留意头指针为空的时候的处理。
三、单向链表的元素删减实现
1、一般思路下,如果想要删除data值对应的结点,我们需要O(n)的时间复杂度来找到这个结点的上一个结点,然后将需要删除的结点上一个结点的next指针指向需要删除结点的下一个结点,以此跳过被删除结点,进行删除,我们先来实现这种思路:
public boolean removeNode(E data){
if(head==null)
return false;
LinkNode<E> temp = head;
if(temp.data == data){//头结点就是需要删除的元素
head = temp.next;//直接把头结点指向原先头结点的下一个结点
//由于原先的头结点不再能够被访问到,所以自动gc
return true;
}
else{
while(temp.next!=null&&temp.next.data!=data){
temp = temp.next;
}
if(temp.next.data==data){//说明找到了待删除结点 其上一个结点是temp
temp.next = temp.next.next;//将待删除结点的上一个结点的next指针指向被删除元素的next指针指向的结点
//被删除元素已经无法被访问到 自动gc
return true;
}
else{
return false;//没找到待删除元素
}
}
}
这种思路需要注意:由于需要查找的是被删除的元素的上一个元素,因此需要对头结点做特殊处理判断。
2、上面这种删除思路的时间复杂度是O(n),因为我们需要找到被删除结点的上一个结点,所以需要我们花费O(n)的时间复杂度去查找该节点,我们能不能实现一种删除,使得已知一个被删除结点,就能直接删除,从而使得时间复杂度达到O(1)呢?
答案当然是可以的,设想我们现在有以下的链表:a->b->c->d,如果我们需要删除结点b,我们可以把结点c复制到结点b,然后把把复制之后的结点b(现在变成了结点c)的next指针指向结点c的next指针指向的结点(也就是结点d),这样便很巧妙地在O(1)复杂度删除了所需要删除的结点。
这种思路需要注意,对于长度为n的链表,前n-1个结点可以在O(1)时间复杂度进行删除,然而对于尾结点就不能这样,我们还是需要顺序查找,因此对于尾结点的删除还是需要O(n)时间复杂度。但是平均时间复杂度是[(n-1)*O(1)+O(n)]/n,结果是O(1),因此此算法的时间复杂度仍然是O(1)。
此算法基于两个假设:被删除的结点需要确保在链表内;传入的参数是被删除结点而不是被删除结点对应的值。下面我们来实现这个算法:
public boolean removeNode2(LinkNode<E> node){
if(node==null||head==null){//头结点为空或者待删除结点为空直接返回false
return false;
}
if(node.next!=null){//待删除结点不是尾结点
LinkNode<E> nextNode = node.next;
//将被删除结点的下一个结点复制给被删除结点
node.data = nextNode.data;
node.next = nextNode.next;
//被删除结点的下一个结点由于没有指针指向它 被GC
}
else if(head == node){//链表只有一个结点 而且恰好是需要被删除的结点
head = null;
}
else{//链表中有多个结点 需要删除尾结点 这种情况需要遍历链表
LinkNode<E> temp = head;
while(temp.next.next!=null){
temp = temp.next;
}//找到尾结点的前一个结点
temp.next = null;
//尾结点由于没有指针指向它 被GC
}
return true;
}
四、单向链表的反向打印
说到单向链表的反向打印,可能很多人会想到链表的倒置,但是反向打印会改变原链表的结构实际上是不合理的。反向打印很明显是一个先进后出的结构,也就是我们可以借用栈来实现这个需求:
public void oppositeDisplay(){
Stack<E> stack = new Stack<E>();
LinkNode<E> temp = head;
while(temp!=null){
stack.push(temp.data);
temp = temp.next;
}
StringBuilder sb = new StringBuilder();
while(!stack.isEmpty()){
sb.append(stack.pop()+" ");
}
System.out.println(sb.toString());
}
既然我们使用了栈,那么我们能想到递归实际上本质就是一个栈结构,因此我们也可以使用递归来实现,每当访问到一个结点,先访问其后面的结点,然后再访问它,但是由于栈空间有一定深度限制和效率影响,上面的方法固然比下面这种要好:
public void oppsiteDisplay2(){//只暴露出这个方法
oppsiteDisplayWithRecursive(head);
}
private void oppsiteDisplayWithRecursive(LinkNode<E> temp){
if(temp!=null){
if(temp.next!=null){
oppsiteDisplayWithRecursive(temp.next);
}
System.out.print(temp.data+" ");
}
}
五、获得单向链表倒数第i个结点
这里我们把倒数第1个结点视为尾结点,正着数的第0个结点视为头结点,看到这个需求,我们脑子里可能很快会蹦出一种很简单的思路,先遍历第一次链表,获得该链表的长度n,该链表的倒数第i个结点实际上就是正着数的第n-i个结点。然而这种思路会遍历链表两次,显然我们需要寻找更好的解法,这里就不实现这种思路了。
现在讲解第二种思路,我们可以用两个指针遍历该链表,第一个指针遍历到第i-1个结点的时候,第二个指针开始和第一个指针一起遍历链表,当第一个指针遍历到链表尾结点的时候,第二个指针就是倒数第i个结点。我们来实现这个算法:
public LinkNode<E> findNodeToTail(int indexToTail){
if(indexToTail<=0)//异常参数直接返回null
return null;
LinkNode<E> fastNode = head;
LinkNode<E> slowNode = head;
int i = 0;
while(fastNode!=null&&i<indexToTail-1){
fastNode = fastNode.next;
i++;
}
if(fastNode == null)//indexToTail大于等于链表的长度 返回null
return null;
while(fastNode.next!=null){
fastNode = fastNode.next;
slowNode = slowNode.next;
}
return slowNode;
}
这里请注意代码的鲁棒性,indexToTail这个参数小于0或者大于等于链表的长度如果不做特殊处理都会导致程序崩溃,一定要考虑周全。
六、单向链表的链表反转
我们还是先来思考这个问题如何解决,对于链表a->b->c->d我们想要反转,首先对于头结点a,我们可以知道它的前一个结点为null,后一个结点为b,访问到结点b的时候,b的前一个节点为a,后一个结点为c。我们先把a,b进行反转,链表变成a<-b c->d,链表从b,c处断开了,因此我们在反转一个结点之前的所有结点的时候需要记录该结点,该结点的前一个结点,该结点的后一个结点,然后挨个反转。下面我们来实现这个算法:
public void reverse(){
if(head == null||head.next == null){//当链表只有一个结点或者没有结点的时候 直接结束
return;
}
LinkNode<E> node = head;
LinkNode<E> nextNode = null;
LinkNode<E> prevNode = null;
while(node!=null){
nextNode = node.next;//先记录当前结点的下一个结点
if(nextNode == null)//遍历到了尾结点 这个时候尾结点就是新的头结点
head = node;
node.next = prevNode;//反转当前结点和前一个结点
prevNode = node;
node = nextNode;
}
}
本算法的关键之处在于记录结点的前一个结点和后一个结点,防止链表反转之后断裂,无法继续反转。另外由于需要记录结点的后一个结点,因此请注意代码的鲁棒性,对链表无结点的情况做特殊处理。
七、合并两个有序的单向链表
两个链表既然是有序的,那么我们可以使用两个指针分别遍历两个链表,每次取出小的一方,放在新的链表的首部,然后继续之前的操作,我们来进行实现:
@SuppressWarnings("unchecked")
public void merge(MyLink<E> link1, MyLink<E> link2){
LinkNode<E> head1 = link1.head;
LinkNode<E> head2 = link2.head;
LinkNode<E> tail = null;
while(head1!=null&&head2!=null){
if(((Comparable<E>)head1.data).compareTo((E)head2.data)<0){
add(head1.data);
tail = head1;
head1 = head1.next;
}
else{
add(head2.data);
tail = head2;
head2 = head2.next;
}
}
if(head1!=null){
while(head1!=null){
add(head1.data);
head1 = head1.next;
}
}
else if(head2!=null){
while(head2!=null){
add(head2.data);
head2 = head2.next;
}
}
}
同样,注意代码的鲁棒性,注意链表为空的时候的特殊处理。
八、判断单向链表有没有环
判断单向链表有没有环很简单,用一个快指针和一个慢指针,如果存在环,快指针和慢指针一定会相遇,否则不会:
//慢指针和快指针如果相遇说明有环
public static<E> boolean judgeRing(MyLink<E> link){
LinkNode<E> slow = link.head;
LinkNode<E> fast = link.head;
while(fast!=null&&fast.next!=null){
slow = slow.next;
fast = fast.next.next;
if(slow == fast)
return true;
}
return false;
}
九、获取单向链表环的长度
如果存在环,从上面方法的碰撞点开始,快指针和慢指针第二次相遇经过的慢指针移动次数就是环的长度:
//记录下碰撞点 快指针满指针从该点开始再次碰撞走过路程就是环的长度
public static<E> int findRingDis(MyLink<E> link){
LinkNode<E> slow = link.head;
LinkNode<E> fast = link.head;
int dis = -1;
boolean isMeetFirstTime = true;
while(fast!=null&&fast.next!=null){
slow = slow.next;
fast = fast.next.next;
if(!isMeetFirstTime)//不是第一次相遇就加距离
dis++;
if(slow == fast&&isMeetFirstTime)//第一次相遇后把相遇位置为false
isMeetFirstTime = false;
else if(slow == fast && !isMeetFirstTime)
return dis;
}
return -1;
}
十、获取单向链表环的连接点
碰撞点到连接点的距离等于头指针到连接点的距离:
//碰撞点到连接点的距离等于头指针到连接点的距离
public static<E> LinkNode<E> getConnectNode(MyLink<E> link){
LinkNode<E> slow = link.head;
LinkNode<E> fast = link.head;
while(fast!=null&&fast.next!=null){
if(slow == fast){
LinkNode<E> impactNode = new LinkNode<E>();
slow = link.head;
while(slow!=impactNode){
slow = slow.next;
impactNode = impactNode.next;
}
return slow;
}
}
return null;//没有环当然不存在连接点
}
十一、获取单向链表环的长度
代码略,前面都分析出了连接点的位置和环的长度,当然也就得出了单向链表环的长度。