5.面试算法-链表之链表基础

1.链表基础

1.1 链表的内部结构

首先看一下什么是链表?单向链表包含多个结点,每个结点有一个指向后继元素的next指针。表中最后一个元素的next指向null。如下图:
在这里插入图片描述
我们在数组中就说过任何数据结构的基础都是创建+增删改查,由这几个操作可以构造很多算法题,所以我们也从这五项开始学习链表。

首先理解JVM是怎么构建出链表的,我们知道JVM里有栈区和堆区,栈区主要存引用的,而堆区存的是对象,栈里的引用存的就是对象在堆区中的地址,例如我们定义这样一个类:

public class Course{
	Teacher teacher;
	Student student;
}

这里的teacher和student就是指向堆的地址。假如我们这样定义:

public class Course {
	int val;
	Course next;
}

这时候next就指向了下一个同为Course的对象了,例如:
在这里插入图片描述
这里的head就存了一个堆内存的地址,所以可以直接找到val(1),然后val(1)结点又存了指向val(2)的地址,而val(3) 又存了指向val(4)的地址,所以就构造出了从head开始的链条访问结构。
我构造了一个例子BasicLink,我们debug一下看一下从head开始next会发现是这样的:
在这里插入图片描述
这就是一个简单的线性访问了。所以而链表就是从head开始,逐个开始向后访问,而每次访问的对象类型都是一 样的。

创建链表的方式可以很简单,在LeetCode中算法题中经常使用这样的方式:

public class ListNode {
	public int val;
	public ListNode next;

	ListNode(int x) {
		val = x;
		next = null;
	}
}
ListNode listnode=new ListNode(1);

这里的val就是当前结点的值, next指向下一个结点。因为两个变量都是public 的,创建对象后能直接使用listnode.val 和listnode.next来操作,虽然违背了面向对象的设计要求,但是代码更为精简,因此在算法题目中应用广泛。

而更加符合面向对象要求的定义是这样的:

public class ListNode {
	private int data;
	private ListNode next;
	public ListNode(int data) {
		this.data = data;
	}
	public int getData() {
		return data;
	}
	public void setData(int data) {
		this.data = data;
	}
	public ListNode getNext() {
		return next;
	}
	public void setNext(ListNode next) {
		this.next = next;
	}
}

这样的坏处是你不知道ListNode表示的是链表还是结点,因此在很多地方还会见到在这个List里为Node单独定义一 个内部类的写法。例如:

public class LinkedListBasicUse {
	static class Node {
		final int data;
		Node next;
		public Node(int data) {
			this.data = data;
		}
	}
}

使用的时候这么用:

LinkedListBasicUse.Node head = new Node(1);

这几种方式都经常见,一般题目会先将使用哪种方式定义的代码告诉你,然后让你来用。所以就会发现不同方式下的链表操作原理一样,但是具体代码千差万别。我们接下来的增删改查操作就使用第二种方式进行。

1.2 遍历链表

对于单链表,不管进行什么操作,一定是从头开始逐个向后访问,所以操作之后是否还能找到表头非常重要。一定要注意"狗熊掰棒子 "问题,也就是只顾当前位置而将标记表头的指针丢掉了。
单链表必须知道表头的地址,然后沿着指针向前走,当next的值为null时停止。

/**
* 获取链表长度
* @param head 链表头节点
* @return 链表长度 
* */
public static int getListLength(Node head) {
	int length = 0;
	Node node = head;
	while (node != null) {
		length++;
		node = node.next;
	}
	return length;
}

1.3 链表插入

单链表的插入,和数组的插入一样,过程不复杂,但是在编码时会发现处处是坑。和数组的插入一样,单链表的插入操作同样要考虑三种情况:首部、中部和尾部。

(1) 在链表的表头插入

链表表头插入新结点非常简单,但是非常容易想不明白。先不看图示和代码等,我们先想想怎么做。

将新数据构造成一个新结点newNode,其next为null。这个新结点怎么连接到原来的链表上呢?应该是 newNode.next=head是吧。

那这时候我们要遍历新链表就要从newNode开始一路next向下了是吧,但是我们还是习惯让head来表示,那怎么办呢?让head=newNode是不是就行了?因此主要分两步:

  • 1.更新结点的next指针,使其指向当前的表头结点。
  • 2.然后更新表头指针的值,使其指向新结点。

图示如下:
在这里插入图片描述

(2)在链表中间插入

在中间位置插入,我们必须先遍历找到要插入的位置,但是到了该位置之后我们不能获得其前面的结点,无法将结点接入进来了,该怎么办呢?我们要在目标结点的前一个位置停下来,也就是通过cur.next来判断。

例如下面图示中,如果要在7的前面插入,当cur到达值为15的结点时,判断cur.next=node(7)了就应该停下来,然后执行插入操作。

很显然new结点前后都要接入,也就是node(15).next=newNode,newNode.next=node(7),这时候该先接哪一个呢?

我们分析一下,7号结点,我们其实是通过cur.next来定位的,如果我们先执行cur.next=new,此时就无法找到7了,因此我们要先接后面。

这时候让newNode.next=cur.next就将后面的虚线建立起来了?然后cur.next=newNode就将新结点接入进来了? 如下图:

此时结点15和7之间的连线(图中红线)就自动断开了,最终结构就是:
在这里插入图片描述
这样就完成了中间任意位置结点的插入。

(3)在单链表的结尾插入结点

表尾插入就比较容易了,但是思想和上面中间位置是一样的。我们只要将尾结点指向新结点就行了。
在这里插入图片描述
综上,我们写出链表插入的方法:

/**
* 链表插入
* @param head       链表头节点
* @param nodeInsert 待插入节点
* @param position   待插入位置
* @return 插入后得到的链表头节点 
* */
public static Node insertNode(Node head, Node nodeInsert, int position) { 
	// 需要判空,否则后面可能会有空指针异常
	if (head == null) {
		return nodeInsert;
	}
	//越界判断
	int size = getLength(head);
	if (position > size + 1 || position < 1) {
		System.out.println("位置参数越界");
		return head;
	}

	//在链表开头插入
	if (position == 1) {
		nodeInsert.next = head;
		// 这里可以直接  return nodeInsert;还可以这么写: head = nodeInsert;
		return head;
	}

	Node pNode = head;
	int count = 1;
	while (count < position - 1) {
		pNode = pNode.next;
		count++;
	}
	nodeInsert.next = pNode.next;
	pNode.next = nodeInsert;
	return head;
}

1.4 链表删除

删除同样分为在删除头部元素,删除中间元素和删除尾部元素。

(1)删除表头结点

删除表头元素还是比较简单的,对于内存泄露要求不高的场景貌似只要返回head=head.next就行了,之前的元素 delNode就再也不能访问了。
在这里插入图片描述

(2)删除最后一个结点

删除的过程不算复杂,重点也是找到要删除的结点curNode的前驱结点preNode,只要将preNode的next设置为 null。例如下图中删除40,其前驱结点为7。
在这里插入图片描述
在操作链表的时候有个问题千万别忘了,不管你后面怎么处理,最后访问链表的时候还是从head开始的,所以一定要保证有一个指针指向head。千万注意别狗熊掰棒子。

(3)删除中间结点

与尾结点类似,这里也要注意保存前驱结点。一旦找到要被删除的结点,将前驱结点next指针的值更新为被删除结 点的next值。如下图所示:
在这里插入图片描述

我们可以写一个删除的方法了:

/**
* 删除节点
*
* @param head     链表头节点
* @param position 删除节点位置,取值从1开始
* @return 删除后的链表头节点 
* */
public static Node deleteNode(Node head, int position) {
	if (head == null) {
		return null;
	}
	int size = getListLength(head);
	if (position > size || position <= 0) { 
		System.out.println("输入的参数有误");
		return head;
	}
	if (position == 1) {
		//curNode就是链表的新head 
		return head.next;
	} else {
		Node preNode = head;
		int count = 1;
		while (count < position - 1) {
			preNode = preNode.next;
			count++;
		}
		Node curNode = preNode.next;
		preNode.next = curNode.next;
	}
	return head;
}

1.5 双向链表简介

双向链表顾名思义就是既可以向前,也可以向后,这是与单向链表最大的区别。有两个指针的好处自然是在指针在中间位置的时候,操作更方便。该结构我们在LRU设计等问题中会遇到,所以这里简单看一下。
在这里插入图片描述

public class Node {
	public int data;
	public Node next;
	public Node prev;

	public Node(int data) {
		this.data = data;
	}
	//打印结点的数据域
	public void displayNode() {
		System.out.print("{" + data + "} ");
	}
}

当然坏处就是增删改的时候,需要修改的指针多了,操作更麻烦了。我们看一下插入和删除的大致过程。

由于在算法测试中,双向链表不是重点,我们只看一下在中间位置删除的操作过程。

该操作与单向链表相似,首先标记出几个关键结点的位置。也就是图中的cur,cur.next和cur.next.next结点。由于在双向链表中,可以走回头路,所以我们使用 cur,cur.next和cur.prev任意一个位置都能实现删除。假如我们就删除cur ,则图示是这样的:
在这里插入图片描述
由于头结点和尾结点的指针情况不一样,所以即使使用了虚拟指针,还是要单独处理一下头部和尾部删除的情况,这个我们不再细谈。

我们再看一下在中间位置插入的情况:
在这里插入图片描述
虽然分析不麻烦,但是看上图的图还是比较繁琐的,而要手动实现也更为麻烦。

与单链表相比,双向链表的一个重要功能是可以方便地进行结点的调整,这在AQS的优先级问题等场景中非常适用。不过单纯的双向链表排序实现非常复杂,而且效率也不高,我们一般会根据实际需要进行简化或者直接使用 jdk封装过的类来实现。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值