4 链表
前面的内容中:动态数组、栈、队列都是底层依赖静态数组、靠resize解决固定容量问题。
- 链表
最简单的、真正的动态数组结构。
更加深入理解引用(指针)
更深入的理解递归
辅助组成其他的数据结构
4.1 链表基本概念
与数组不同,数据随机分布在内存中的各个位置的存储结构称为线性表的链式存储。
4.1.1 定义
-
单向链表
单向链表是链表的一种,它由节点组成,每个节点都包含下一个节点的指针。
-
双向链表
在单向链表的基础上,给各个结点额外配备一个指针变量,用于指向每个结点的直接前趋元素。
-
循环链表
当单向链表的尾部数据指向头部数据时,就构成了单向循环链表。
当双向链表的头部和尾部相互指向时,就构成了双向循环链表。
4.1.2 链表的特点
- 数据存储在节点Node中
class Node{
E e;
Node next;
}
- 链表结构:
- 链表的优缺点
-
优点: 真正的动态,不需要处理固定容量问题。
不像数组一下子必须new出来一片空间,需要考虑空间不够用或浪费。链表是你需要多少个数据,就生成多少个节点将他挂接起来,这就是所谓的动态的意思。 -
缺点: 丧失了随机访问能力。
不能像数组一样,给定一个索引直接拿出对应元素。底层机制中数组开辟的空间在内存中是连续分布的,我们可以直接寻找索引对应的偏移,直接计算出数据所存储的内存地址,直接用O(1)复杂度拿出。链表靠next连接,每个节点存储地址不同,我们只能通过next顺藤摸瓜找到我们要找的元素。
- 数组与链表两种数据结构的比较
- 数组最大的优点:支持快速查询;
- 链表最大的优点:动态。
- 在链表头部添加元素方便,在数组在数组尾部添加元素方便。
4.2 链表的代码实现
4.2.1 节点类的定义与构造
private class Node {
public E e;
public Node next;
public Node(E e, Node next) {
this.e = e;
this.next = next;
}
public Node(E e) {
this.e = e;
this.next = null;
}
public Node() {
this(null, null);
}
@Override
public String toString() {
return e.toString();
}
}
4.2.2 在链表头添加元素:
//在链表头添加新的元素
public void addFirst(E e){
// Node node=new Node(e);
// node.next=head;
// head=node;
head=new Node(e,head);
size++;
}
4.2.3 在链表中间添加元素
首先要创建要插入的节点。在链表中间插入元素的关键是要找到添加的节点的前一个节点prev。
添加在链表头由于没有前一个节点所以要特殊处理。
代码的顺序很重要,更改顺序会出错。
//在链表中的index位置添加元素
//在链表中不是常用的操作
public void add(int index,E e){
if(index<0||index>size){
throw new IllegalArgumentException("Add failed,Illegal index");
}
if(index==0){
addFirst(e);
}else{
Node prev=head;
for(int i=0;i<index-1;i++)
prev=prev.next;
// Node node=new Node(e);
// node.next=prev.next;
// prev.next=node;
prev.next=new Node(e,prev.next);
size++;
}
}
虚拟头节点
由于在链表头添加元素与其他位置添加元素逻辑不一样,需要对链表头部的操作进行特殊处理,造成了程序逻辑编写的不方便。添加一个头结点可以帮助各种操作的逻辑得以统一。
private Node dummyHead;
private int size;
public LinkedList(){
dummyHead=new Node(null,null);
size=0;
}
4.2.4链表的遍历、查询和修改
//获取链表的第index(0-based)个位置的元素
//在链表中不常用
public E get(int index){
if(index<0||index>=size)
throw new IllegalArgumentException("Get failed,Illegal index");
Node cur=dummyHead.next;
for(int i=0;i<index;i++){
cur=cur.next;
}
return cur.e;
}
//获得链表的第一个元素
public E getFirst(){
return get(0);
}
//获得链表的最后一个元素
public E getLast(){
return get(size-1);
}
//修改链表的第index个位置的元素为e
//在链表中不是一个常用的操作
public void set(int index,E e){
if(index<0||index>=size)
throw new IllegalArgumentException("Set failed,Illegal index");
Node cur=dummyHead.next;
for(int i=0;i<index;i++)
cur=cur.next;
cur.e=e;
}
//查找链表中是否有元素e
public boolean contains(E e){
Node cur=dummyHead.next;
while(cur!=null){
if(cur.e.equals(e))
return true;
cur=cur.next;
}
return false;
}
4.2.5 链表元素的删除
关键是找到待删除节点之前的那个节点
public E remove(int index){
if(index<0||index>=size)
throw new IllegalArgumentException("Remove failed,Index is Illegal");
Node prev=dummyHead;
for(int i=0;i<index;i++){
prev=prev.next;
}
Node delNode=prev.next;
prev.next=delNode.next;
delNode.next=null;
size--;
return delNode.e;
}
4.3 链表的时间复杂度
- 对指定位置进行节点的增删改查操作
时间复杂度为O(n);
(需要根据位置对链表中的元素进行遍历,平均是需要进行n/2次操作,故时间复杂度为O(n)) - 对头节点进行操作
时间复杂度为O(1);
4.4 链表与递归
4.4.1 递归
-
本质上是将原来的问题转化为更小的同一问题
-
举例:数组求和:
sum(0,n)=arr[0]+·······+arr[n]=arr[0]+sum(1,n);
private static int sum(int[] arr,int l){
if(l==arr.length)
return 0;
return arr[l]+sum(arr,l+1);
}
- 写递归算法的基本原则:
- 求解最基本问题
- 把原问题转化成更小的问题
- 要根据更小的问题构成更难的问题的答案
4.4.2 链表的天然递归性
- 案例:删除链表中的元素
package Leetcode;
public class SolutionRecursion {
public ListNode removeElements(ListNode head,int val){
if(head==null)
return null;
head.next=removeElements(head.next,val);
return head.val==val?head.next:head;
}
public static void main(String[] args) {
int[] nums={1,2,6,3,4,5,6};
ListNode head=new ListNode(nums);
System.out.println(head);
ListNode res=(new SolutionRecursion()).removeElements(head,6);
System.out.println(res);
}
}
递归函数的微观解读
回忆:程序调用的系统栈
- 递归函数的调用和函数调用没有区别,只是调用的子函数是函数本身。
递归算法的调试
- 删除链表中的元素
public class SolutionRecursion {
public ListNode removeElements(ListNode head, int val, int depth) {
String depthString = generateDepthString(depth);
System.out.print(depthString);
System.out.println("Call: remove " + val + " in " + head);
if(head == null){
System.out.print(depthString);
System.out.println("Return: " + head);
return head;
}
ListNode res = removeElements(head.next, val, depth + 1);
System.out.print(depthString);
System.out.println("After remove " + val + ": " + res);
ListNode ret;
if(head.val == val)
ret = res;
else{
head.next = res;
ret = head;
}
System.out.print(depthString);
System.out.println("Return: " + ret);
return ret;
}
private String generateDepthString(int depth){
StringBuilder res = new StringBuilder();
for(int i = 0 ; i < depth ; i ++)
res.append("--");
return res.toString();
}
public static void main(String[] args) {
int[] nums = {1, 2, 6, 3, 4, 5, 6};
ListNode head = new ListNode(nums);
System.out.println(head);
ListNode res = (new SolutionRecursion()).removeElements(head,6, 0);
System.out.println(res);
}
}
4.5 其他链表
4.5.1 带有尾指针的链表
需要有一个tail指针指向链表的尾部,
从head端删除元素,从tail端插入元素
4.5.2 双链表
每个节点有两个指针,一个指针指向节点的后一个元素,一个节点指向节点的前一个元素。
构造方法:
class Node{
E e;
Node next,prev;
}
4.5.3 循环链表
尾节点不指向空,指向虚拟头节点。可以用下一个节点是否是虚拟节点判断是否为尾节点。
4.5.4 数组链表
数组中不仅存储元素,并且存储下一个位置的索引。
在明确知道需要处理的数组长度时使用比较好。
4.6 链表vs数组 栈
package DataStructure;
import java.util.Random;
public class StackArrayLinkedMain {
// 测试使用stack运行opCount个push和pop操作所需要的时间,单位:秒
private static double testStack(Stack<Integer> stack, int opCount){
long startTime = System.nanoTime();
Random random = new Random();
for(int i = 0 ; i < opCount ; i ++)
stack.push(random.nextInt(Integer.MAX_VALUE));
for(int i = 0 ; i < opCount ; i ++)
stack.pop();
long endTime = System.nanoTime();
return (endTime - startTime) / 1000000000.0;
}
public static void main(String[] args) {
int opCount = 100000;
ArrayStack<Integer> arrayStack = new ArrayStack<>();
double time1 = testStack(arrayStack, opCount);
System.out.println("ArrayStack, time: " + time1 + " s");
LinkedListStack<Integer> linkedListStack = new LinkedListStack<>();
double time2 = testStack(linkedListStack, opCount);
System.out.println("LinkedListStack, time: " + time2 + " s");
// 其实这个时间比较很复杂,因为LinkedListStack中包含更多的new操作
}
}