目录
一、链表Linked List
动态数组、栈和队列底层是依托静态数组实现的,靠resize()解决固定容量问题,而链表是一种真正的动态数据结构。
链表的数据存储在节点(Node)中。
如下所示代码,节点包含两部分的内容,一部分是存储真正的数据E e;另一部分就是节点本身,代表的是当前节点的下一个节点。
public class Node {
E e;
Node next;
}
就好比火车,存储内容的节点就如同一个车厢,而车厢才是内容的真正载体,车厢与车厢之间又进行关联,从而构成一个整体。
链表的优点:不需要处理固定容量的问题(动态)。
缺点:丧失了随机访问的能力。数组具备随机访问能力的原因是数组开辟的空间在内存里是连续的,所以可以通过索引快速的进行元素访问,而链表的存储位置是不连续的,它是必须通过节点一个一个串联起来查找。
因此我们可以发现:数组适合查找,而链表适合增删。
如下是在代码中维护一个内部节点的示例:
public class LinkedList<E> {
// 内部使用私有内部类维护一个节点
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,null);
}
public Node(){
this(null,null);
}
@Override
public String toString(){
return e.toString();
}
}
}
1、往链表中添加元素
在链表中,我们维护一个head,用来指示链表的第一个节点,维护一个size,用来指示链表中存储的元素个数。设计图示如下所示:
(1)往链表头部添加元素
在这样的链表当中,如果我们想要往链表中添加一个元素,会发现我们有一个头来跟踪链表的头,但是没有相应的元素来跟踪链表的尾,所以这个时候,我们去往链表的头添加元素会比较方便。
如图,我们需要把一个节点666添加到链表中。首先,我们需要把这个node的next指向这个链表的头:node.next = node;然后维护一下head,使他指向新的头节点666:head = node。完成结果图示如下:
代码实现如下(在上边的代码中添加):
private Node head;
private int size;
// 初始化链表
public LinkedList(){
head = null;
size = 0;
}
// 获取链表中的元素个数
public int getSize(){
return size;
}
// 返回链表是否为空
public boolean isEmpty(){
return size == 0;
}
// 在链表头添加新的元素
public void addFirst(E e){
// 实现步骤
// Node node = new Node(e);
// node.next = head;
// head = node;
// 也可以写成这样
head = new Node(e,head);
size ++;
}
(2)往链表中间和末尾添加元素
我们如果想要往链表的某两个节点中间插入一个数据,比如,在链表中2的位置插入一个新的节点666。这个时候,我们需要找到2这个节点之前的那个节点prev;然后把prev节点的next赋值给node.next;再把prev的next更新成node;这样操作之后,便成功的把666这个节点加入了这两个节点之间。
注意:以上操作的顺序很重要。即不能先把把prev的next更新成node;然后再把prev节点的next赋值给node.next。这是因为prev的next在前边的赋值中就已经被替换掉了。
同样,如果是往链表的末尾添加数据,就更简单了,只需要把操作节点的位置锁定在最后一个节点的前一个节点上就可以了。
以上思路的代码实现如下:
// 在链表的index(0-size)位置添加新的元素
// 在链表中不是一个常用操作,练习用
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++;
}
}
// 在链表的末尾添加新的元素e
public void addLast(E e) {
add(size, e);
}
(3)使用虚拟头节点
在上边的操作中,我们需要对在链表头部添加元素做特殊的处理,即if(index == 0) addFirst(e)。现在我们换一种思路,即在初始化链表的时候,就创建一个虚拟的头节点dummyHead;这个虚拟头节点处在图中0位置的前一个位置(也就是占据了链表的第一个位置,只是这个位置的节点存储的是空值),如下所示:
根据上述思路,代码的实现如下:
private Node dummyHead;
private int size;
// 初始化链表
public LinkedList(){
// 创建虚拟头节点
dummyHead = new Node(null,null);
size = 0;
}
// 获取链表中的元素个数
public int getSize(){
return size;
}
// 返回链表是否为空
public boolean isEmpty(){
return size == 0;
}
// 在链表的index(0-size)位置添加新的元素
public void add(int index, E e) {
if (index < 0 || index > size) {
throw new IllegalArgumentException("Add failed,Illegal index");
}
// 从第一个头节点开始遍历,此时是虚拟头节点占据了第一个位置
Node prev = dummyHead;
// 寻找插入节点之前的前一个位置的节点
for (int i = 0; i < index; i++) {
// 不断覆盖下一个节点的连接,直到目标节点的前一个节点
prev = prev.next;
}
// 创建节点/进行交换操作
prev.next = new Node(e, prev.next);
size++;
}
// 在链表头添加新的元素
public void addFirst(E e) {
add(0, e);
}
// 在链表的末尾添加新的元素e
public void addLast(E e) {
add(size, e);
}
2、链表的查询和修改
链表的查询和修改,都是围绕着特定的节点操作而展开。如上,增加操作是操作指定位置的前一个节点,那么修改和查询操作就是操作索引当前位置的节点了,只不过,我们设立了虚拟头节点,所以,这个节点就是当前节点的下一个节点。
注:链表没有索引的概念,这里为了表述,所以叫做索引。
接下来,看一下具体代码的实现。在前面代码的基础上添加如下代码:
// 获取链表中index(0-size)位置的元素
// 在链表中不是一个常用操作,练习用
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 get() {
return get(size - 1);
}
// 修改链表中index(0-size)位置的元素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;
}
@Override
public String toString() {
StringBuilder sb = new StringBuilder();
// Node cur = dummyHead;
// while (cur != null) {
// sb.append(cur + "->");
// cur = cur.next;
// }
// 循环还可以这样写——>开始条件;结束条件;中间操作
for(Node cur = dummyHead.next;cur != null;cur = cur.next){
sb.append(cur + " -> ");
}
sb.append("NULL");
return sb.toString();
}
public static void main(String[] args) {
LinkedList<Integer> list = new LinkedList<Integer>();
for(int i =0;i<5;i++){
// 使用addFirst,因为它是O(1)级别的操作
list.addFirst(i);
System.out.println(list);
}
// 在索引为2的位置添加新的元素666
list.add(2,666);
System.out.println(list);
}
综合上述代码的执行结果如下:
3、从链表中删除元素
从链表中删除元素,我们也需要找到删除节点前的那个节点,比如删除2这个位置的元素,我们就需要把2这个位置的节点的next赋值到1这个位置的next上,实现下图所示操作。当然,为了java能及时进行垃圾回收,我们还需要把删除节点的元素进行置空。
以下是删除的代码逻辑实现
// 从链表中删除index(0-size)位置的元素e
// 在链表中不是一个常用操作,练习用
public E remove(int index) {
if (index < 0 || index >= size) {
throw new IllegalArgumentException("remove failed , Illegal index");
}
Node prev = dummyHead;
for (int i = 0; i < index; i++) {
prev = prev.next;
}
// 被删除的节点
Node retNode = prev.next;
prev.next = retNode.next;
// 被删除的节点不再跟其他节点来连接,彻底跟链表脱离关系
retNode.next = null;
size --;
return retNode.e;
}
// 从链表中删除第一个元素
public E removeFirst() {
return remove(0);
}
// 从链表中删除最后一个元素
public E removeLast() {
return remove(size - 1);
}
二、链表的时间复杂度分析
1、添加操作
2、删除操作
3、查找操作
在链表中没有设计跟数组一样的find(e),这是因为即便是拿到了元素的索引,也不能快速的进行访问,所以这个方法是没有意义的。
4、修改操作
5、时间复杂度总结
如上分析,链表的时间复杂度,增、删、改、查都是O(n)级别的,所以它的整体性能要比数组差,因为链表不能像数组那样,可以使用索引进行快速的数据访问。但是,当我们只在链表的头进行增、删、查操作时,它的时间复杂度是O(1)级别的,又因为链表整体是动态的,所以不会大量的浪费内存空间,因此具备有一定的优势。
三、使用链表实现栈
如上所述,对于我们的自定义链表来说,如果只对链表头进行操作,这样的操作级别是O(1)的。这种只对头部元素进行操作的数据结构跟栈比较相似,下边,我们通过自定义链表来实现一个栈。
在实现栈之前,我们继续实现通过自定义数组来实现栈的那个接口:Stack,然后来对比一下两个不同底层实现之间所带来的性能差异。
Stack接口:
public interface Stack<E> {
// 获取栈中数据量
int getSize();
// 判断栈是否为空
boolean isEmpty();
// 向栈顶推送元素
void push(E e);
// 从栈中取出元素
E pop();
// 查看栈顶元素
E peek();
}
通过链表实现的栈的代码:
public class LinkedListStack<E> implements Stack {
private LinkedList<E> list;
// 链表没有容量这个概念,因此不需要设置容量
public LinkedListStack() {
list = new LinkedList<E>();
}
@Override
public int getSize() {
return list.getSize();
}
@Override
public boolean isEmpty() {
return list.isEmpty();
}
@Override
public void push(Object e) {
// 向链表头部添加元素是O(1)级别的,头部为栈顶
list.addFirst((E) e);
}
@Override
public Object pop() {
return list.removeFirst();
}
@Override
public Object peek() {
return list.getFirst();
}
@Override
public String toString() {
StringBuilder sb = new StringBuilder();
sb.append("Stack: top ");
sb.append(list);
return sb.toString();
}
public static void main(String[] args) {
int opCount = 1000000;
ArrayStack<Integer> arrayStack = new ArrayStack<Integer>();
double time1 = testStack(arrayStack, opCount);
System.out.println("arrayStack :" + time1 + " s");
// linkedListStack包含更多new操作
LinkedListStack<Integer> linkedListStack = new LinkedListStack<Integer>();
double time2 = testStack(linkedListStack, opCount);
System.out.println("linkedListStack :" + time2 + " s");
}
private static double testStack(Stack<Integer> stack, int count) {
long startTime = System.nanoTime();
Random random = new Random();
for (int i = 0; i < count; i++) {
// 插入一个从0到int最大数之间的一个随机值
stack.push(random.nextInt(Integer.MAX_VALUE));
}
for (int i = 0; i < count; i++) {
stack.pop();
}
long endTime = System.nanoTime();
// 以秒为单位
return (endTime - startTime) / 1000000000.0;
}
}
上述代码中,在main函数内部写了一个ArrayStack和LinkedListStack的方法,两者的测试结果如下:
可以看到LinkedListStack在10万次的运行中会比ArrayStack相对快那么一点,但是差距不会很大,因为这两者的操作级别都是O(1)的,并不会像O(1)和O(n)对比的差距那么明显。不过,在这里,其实这个结论也是站不住脚的,当执行次数达到100万级别后,ArrayStack反而会比LinkedListStack用的操作时间要少,这是因为在LinkedListStack的操作中,需要不断的去new一个对象,不断的去内存开辟空间,因此当执行量比较大时,这样反而会更加损耗性能,读者可自行尝试。
四、使用链表实现队列
上边,我们使用链表实现了栈这种数据结构,接下来,我们使用链表来实现队列。因为队列是操作数据结构的两端,而链表只有在操作头部节点的时候,它的操作级别才是O(1)的。那么就队列来说,同时操作头和尾,是不是必有一种操作时O(n)级别的呢?
为了使我们通过链表实现的队列也具备良好的性能,我们稍微改变了一下链表的维护方式。如下图,我们使用tail用来指示链表的尾部,当我们需要向链表尾部添加元素时,就不需要再去循环遍历整个链表了。不过,当我们需要删除链表尾部的数据时,我们仍然不得不去进行整个链表的遍历。正因为如此,在通过链表实现队列时,我们选择在head端删除元素,在tail端添加元素,这样两个操作就都是O(1)级别的了。
同样的,我们需要继承Queue接口
public interface Queue<E> {
int getSize();
boolean isEmpty();
// 放入元素
void enqueue(E e);
// 拿出元素
E dequeue();
// 查看队列元素
E getFornt();
}
具体代码实现如下:
public class LinkedListQueue<E> implements Queue{
// 内部使用私有内部类维护一个节点
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,null);
}
public Node(){
this(null,null);
}
@Override
public String toString(){
return e.toString();
}
}
private Node head,tail;
private int size;
public LinkedListQueue(){
head = null;
tail = null;
size = 0;
}
@Override
public int getSize(){
return size;
}
@Override
public boolean isEmpty() {
return size == 0;
}
@Override
public void enqueue(Object e) {
if(tail == null){
tail = new Node((E) e);
head = tail;
}else{
tail.next = new Node((E) e);
tail = tail.next;
}
size ++;
}
@Override
public E dequeue() {
if(isEmpty()){
throw new IllegalArgumentException("Cannot dequeue from an empty queue.");
}
Node retNode = head;
head = head.next;
retNode.next = null;
if(head == null){
// 删除的元素时链表中唯一的元素,需要维护下tail
tail = null;
}
size --;
return retNode.e;
}
@Override
public E getFornt() {
if(isEmpty()){
throw new IllegalArgumentException("Queue is empty.");
}
return head.e;
}
@Override
public String toString(){
StringBuilder sb = new StringBuilder();
sb.append("Queue: front");
Node cur = head;
while(cur != null){
sb.append(cur + "->");
cur = cur.next;
}
sb.append("NULL tail");
return sb.toString();
}
public static void main(String[] args) {
int opCount = 100000;
LoopQueue<Integer> loopQueue = new LoopQueue<Integer>();
double time1 = testQueue(loopQueue, opCount);
System.out.println("LoopQueue :" + time1 + " s");
ArrayQueue<Integer> arrayQueue = new ArrayQueue<Integer>();
double time2 = testQueue(arrayQueue, opCount);
System.out.println("arrayQueue :" + time2 + " s");
LinkedListQueue<Integer> linkedListQueue = new LinkedListQueue<Integer>();
double time3 = testQueue(linkedListQueue, opCount);
System.out.println("linkedListQueue :" + time3 + " s");
}
private static double testQueue(Queue<Integer> queue, int count) {
long startTime = System.nanoTime();
Random random = new Random();
for (int i = 0; i < count; i++) {
// 插入一个从0到int最大数之间的一个随机值
queue.enqueue(random.nextInt(Integer.MAX_VALUE));
}
for (int i = 0; i < count; i++) {
queue.dequeue();
}
long endTime = System.nanoTime();
// 以秒为单位
return (endTime - startTime) / 1000000000.0;
}
}
以上代码中,增加了队列三种实现方式的性能比较,执行结果如下
其中,我们发现LoopQueue和linkedListQueue执行消耗的时间基本上都差不多,但是arrayQueue消耗的时间远远超过了前两者。这是因为前两者的时间复杂度是O(1)级别的,而arrayQueue的时间复杂度是O(n)级别的。从这个对比中,我们可以看出,不同时间复杂度的操作在性能上的差距。