在前面实现的三种线性数据结构:动态数组、栈和队列 虽然对用户而言实现了动态的功能,但在底层上仍是依托着静态数组,使用 resize 方法解决固定容量的问题,从根本上来讲还不是真正的动态。html
而对于链表而言,则是真正的动态数据结构。java
由于链表的实现是将一个个节点靠地址的指向将这些节点挂接起来而组成的。数组
简单来讲,每一次在链表上添加新数据就是在一个已有节点的指针域上指定它的下一个节点的地址为存放新数据的节点的地址。这样子,不管是从底层上仍是用户的角度上,都不用担忧容量的问题,因此链表是真正的动态数据结构。数据结构
一样,链表也是一个很重要的数据结构。对于链表而言,它是最简单的动态数据结构,能够帮助咱们更深刻地理解引用(指针)、更深刻地理解递归以及能够用来辅助组成其余的数据结构。app
对链表而言,数据是存储在“节点”(Node)中的,可使用一个数据域来存储数据,这里我称为 element;而后节点中还有一个用来指向下一个节点位置的节点域,通常称为 next。而对于链表的结尾,通常是以 NULL 做为结尾,因此链表中的最后一个节点的节点域 next 指向的是 NULL。dom
图示以下:ide
因此能够先暂时设计链表的基本结构代码以下:函数
/** * 链表类 * 支持泛型 * * @author 踏雪彡寻梅 * @date 2020-02-03 - 21:08 */ public class LinkedList<E> { /** * 链表的节点 * 对于用户而言,不须要知道链表的底层结构是怎样的,只须要知道链表是一种线性数据结构,能够增删改查数据 */ private class Node { /** * 节点存储的数据 */ public E element; /** * 用于指向下一个节点,使节点与节点之间挂接起来组成链表 */ public Node next; /** * 构造函数 * 构造一个存有数据并指向了下一个节点的节点 * * @param element 存往该节点中的数据 * @param next 该节点的下一个节点 */ public Node(E element, Node next) { this.element = element; this.next = next; } /** * 构造函数 * 构造一个存有数据但没有指向下一个节点的节点 * * @param element 存往该节点中的数据 */ public Node(E element) { this(element, null); } /** * 构造函数 * 构造一个空节点 */ public Node() { this(null, null); } /** * 重写 toString 方法以显示节点中存储的数据信息 * * @return 返回节点中存储的数据信息 */ @Override public String toString() { return element.toString(); } } }
从以上设计也可简单的分析链表的优缺点以下:oop
优势:真正的动态结构,不须要处理固定容量的问题。测试
缺点:丧失了随机访问的能力。即不像数组同样能够经过索引快速地获取到数据。
综上,可简单对比数组和链表的使用场景以下:
数组最好用于索引有语意的状况,不适合用于索引没有语意的状况。
有语意的状况:如一个班级中第二名的分数可这样表示:score[2]。
数组也能够没有语意,并非任什么时候候索引都是有语意的,不是全部有语意的这样的一个标志就适合作索引,如身份证号:身份证号的保存会存在空间的浪费(有些索引不是身份证号码)。
最大的优势:支持快速查询。
相比数组,将一个静态数组改变为一个动态数组,就是在对于不方便使用索引的时候处理有关数据存储的问题,对于这样的存储数据的需求使用链表是更合适的。因此链表不适合用于索引有语意的状况,更适合处理索引没有语意的状况。
另外,对于查看链表中的各个元素,也是须要一一遍历过去的,那么此时就须要一个变量 head 来指向链表头部的位置,以便查看链表信息所用。同时由于有了这个变量 head 来指向链表头的位置,那么往链表头部添加新元素是十分方便的,这和以前实现的数组数据结构在数组尾部添加元素十分方即是同一个道理,数组中有 size 变量指向下一个新元素位置跟踪尾部。
此时链表的结构以下图所示:
此时设计链表基本结构代码以下,其中使用了一个变量 size 来实时记录链表元素的个数以及增长了两个基本方法用于获取链表当前元素个数和判断链表是否为空:
/** * 链表类 * 支持泛型 * * @author 踏雪彡寻梅 * @date 2020-02-03 - 21:08 */ public class LinkedList<E> { /** * 链表的节点 * 对于用户而言,不须要知道链表的底层结构是怎样的,只须要知道链表是一种线性数据结构,能够增删改查数据 */ private class Node { /** * 节点存储的数据 */ public E element; /** * 用于指向下一个节点,使节点与节点之间挂接起来组成链表 */ public Node next; /** * 构造函数 * 构造一个存有数据并指向了下一个节点的节点 * * @param element 存往该节点中的数据 * @param next 该节点的下一个节点 */ public Node(E element, Node next) { this.element = element; this.next = next; } /** * 构造函数 * 构造一个存有数据但没有指向下一个节点的节点 * * @param element 存往该节点中的数据 */ public Node(E element) { this(element, null); } /** * 构造函数 * 构造一个空节点 */ public Node() { this(null, null); } /** * 重写 toString 方法以显示节点中存储的数据信息 * * @return 返回节点中存储的数据信息 */ @Override public String toString() { return element.toString(); } } /** * 链表的头节点 * 存储第一个元素的节点 */ private Node head; /** * 链表当前元素个数 */ private int size; /** * 构造函数 * 构造一个空链表 */ public LinkedList() { head = null; size = 0; } /** * 获取链表中的当前元素个数 * * @return 返回链表当前元素个数 */ public int getSize() { return size; } /** * 判断链表是否为空 * * @return 链表为空返回 true;不然返回 fasle */ public boolean isEmpty() { return size == 0; } }
在上文介绍过,在链表头部添加元素是十分方便的,因此先实现这个操做。
对于这个操做,实现的具体步骤以下:
建立一个新节点 newNode 存储新元素 newElement,新节点的节点域 next 指向 NULL。
将 newNode 的 节点域 next 指向当前链表头 head,使新节点挂接在链表头部。即 newNode.next = head。
最后将 head 指向 newNode,使链表头为新增的节点。即 head = newNode。
综上,在链表头添加过程以下图所示:
设计在链表头部添加元素代码以下所示:
/** * 在链表头添加新的元素 newElement * * @param newElement 新元素 */ public void addFirst(E newElement) { // 建立一个新节点存储新元素,该节点的 next 指向 NULL Node newNode = new Node(newElement); // 使 newNode 的 next 指向链表头 newNode.next = head; // 将链表头设为链表新添加的新节点 head = newNode; // 以上三行代码可以使用 Node 的另外一个构造函数简写为: // head = new Node(newElement, head); // 维护 size,链表当前元素个数 + 1 size++; }
除了在链表头部添加元素,还能够指定一个位置来进行添加元素。这个操做在链表的操做中不常使用,通常常出如今试题中,这里实现出来用来帮助深刻理解链表的思惟。
对于这个操做,指定的添加元素位置这里设计为用 index 表示(从 0 开始计数),实现的具体步骤以下:
判断指定的添加位置 index 是否为合法值。
使用一个节点变量 prev 来找到指定插入位置 index 的前一个节点位置,初始时 prev 指向链表头 head。
建立一个新节点 newNode 存储新元素 newElement,新节点的节点域 next 指向 NULL。
使用 prev 找到指定位置 index 的前一个位置(index - 1 处,即插入位置的前一个节点)后,将 newNode 的 next 指向 prev 的 next 指向的节点,即将新节点挂接在插入位置的原节点前面。(newNode.next = prev.next)
将 prev 的 next 指向新节点 newNode,即将链表先后都挂接了起来。此时新节点处于 index 处,而原来处于 index 的节点和以后的节点都日后挪了一个位置。(prev.next = newNode)
对于以上步骤,关键在于找到要添加的节点的前一个节点。
而找到前一个节点这个操做有一个特殊状况,即指定添加位置 index 为 0 的时候,也就是将元素添加到链表头,而链表头是没有前一个节点的(对于链表头没有前一个节点后续会实现一个虚拟头节点放置到链表头的前一个节点,方便链表的操做)。
因此这个操做须要进行特殊处理:
使用一个判断判断 index 是否为 0,若是为 0 使用前面实现的 addFirst(E newElement) 方法将新节点添加到链表头。
综上,在链表指定位置处添加元素过程以下图所示:
须要注意的是 newNode.next = prev.next 和 prev.next = newNode 的顺序不能相反,不然将会出现错误,具体结果图示以下:
设计在链表指定位置处添加元素代码以下所示:
/** * 在链表的指定位置 index(从 0 开始计数)处添加新元素 newElement * * @param index 指定的添加位置,从 0 开始计数 * @param newElement 新元素 */ public void add(int index, E newElement) { // 判断 index 是否合法 if (index < 0 || index > size) { throw new IllegalArgumentException("Add failed.Illegal index."); } if (index == 0) { // 若是 index 为 0,使用 addFirst(E newElement) 方法将新元素添加到链表头。 addFirst(newElement); } else { // 不然将新元素添加到 index 处 // 找到 index 的前一个节点 Node prev = head; for (int i = 0; i < index - 1; i++) { prev = prev.next; } // 建立一个新节点存储新元素,该节点的 next 指向 NULL Node newNode = new Node(newElement); // 将新节点添加到 index 处 newNode.next = prev.next; prev.next = newNode; // 以上三行代码可以使用 Node 的另外一个构造函数简写为: // prev.next = new Node(newElement, prev.next); // 维护 size,链表当前元素个数 + 1 size++; } }
由以上实现可复用其实现一个在链表末尾添加新元素的方法 addLast:
/** * 在链表末尾添加新的元素 newElement * @param newElement 新元素 */ public void addLast(E newElement) { add(size, newElement); }
在上面实现的在链表指定位置处添加元素中,能够发现有一个特殊状况为指定在头部添加元素时,头部元素没有前一个节点,因此须要作一个特殊处理。为了让这个操做统一为每一个节点均可以找到前置节点,须要在链表中设置一个虚拟头节点 dummyHead。这个节点这里设计为不存储数据,只用于指向链表中的第一个元素。
添加虚拟头节点后的链表基本结构图示以下:
此时更改链表的实现代码以下:
/** * 链表类 * 支持泛型 * * @author 踏雪彡寻梅 * @date 2020-02-03 - 21:08 */ public class LinkedList<E> { /** * 链表的节点 * 对于用户而言,不须要知道链表的底层结构是怎样的,只须要知道链表是一种线性数据结构,能够增删改查数据 */ private class Node { /** * 节点存储的数据 */ public E element; /** * 用于指向下一个节点,使节点与节点之间挂接起来组成链表 */ public Node next; /** * 构造函数 * 构造一个存有数据并指向了下一个节点的节点 * * @param element 存往该节点中的数据 * @param next 该节点的下一个节点 */ public Node(E element, Node next) { this.element = element; this.next = next; } /** * 构造函数 * 构造一个存有数据但没有指向下一个节点的节点 * * @param element 存往该节点中的数据 */ public Node(E element) { this(element, null); } /** * 构造函数 * 构造一个空节点 */ public Node() { this(null, null); } /** * 重写 toString 方法以显示节点中存储的数据信息 * * @return 返回节点中存储的数据信息 */ @Override public String toString() { return element.toString(); } } /** * 链表的虚拟头节点 * 不存储数据 * next 指向链表中的第一个元素 */ private Node dummyHead; /** * 链表当前元素个数 */ private int size; /** * 构造函数 * 构造一个空链表 */ public LinkedList() { // 建立虚拟头节点,存储 null,初始时 next 指向 null dummyHead = new Node(null, null); size = 0; } /** * 获取链表中的当前元素个数 * * @return 返回链表当前元素个数 */ public int getSize() { return size; } /** * 判断链表是否为空 * * @return 链表为空返回 true;不然返回 fasle */ public boolean isEmpty() { return size == 0; } /** * 在链表的指定位置 index(从 0 开始计数)处添加新元素 newElement * * @param index 指定的添加位置,从 0 开始计数 * @param newElement 新元素 */ public void add(int index, E newElement) { // 判断 index 是否合法 if (index < 0 || index > size) { throw new IllegalArgumentException("Add failed.Illegal index."); } // 将新元素添加到 index 处 // 找到 index 的前一个节点 Node prev = dummyHead; for (int i = 0; i < index; i++) { prev = prev.next; } // 建立一个新节点存储新元素,该节点的 next 指向 NULL Node newNode = new Node(newElement); // 将新节点添加到 index 处 newNode.next = prev.next; prev.next = newNode; // 以上三行代码可以使用 Node 的另外一个构造函数简写为: // prev.next = new Node(newElement, prev.next); // 维护 size,链表当前元素个数 + 1 size++; } /** * 在链表头添加新的元素 newElement * * @param newElement 新元素 */ public void addFirst(E newElement) { add(0, newElement); } /** * 在链表末尾添加新的元素 newElement * * @param newElement 新元素 */ public void addLast(E newElement) { add(size, newElement); } }
此时,在 add 方法的实现中添加新元素的操做就都统一为同一个步骤了,每个节点都能找到其前置节点。
须要注意的是,和以前 prev 从链表的第一个元素开始遍历寻找更改成了从虚拟头节点开始遍历寻找,因此遍历的终止条件从 i < index - 1 变为了 i < index。为了方便理解这个过程,能够参考如下图示:
原实现:
现实现:
在更改了 add 方法以后,addFirst 方法也可精简为复用 add 方法就可实如今链表头部添加元素的功能了。这也是使用了虚拟头节点以后带来的便利。
对于此操做,这里实现两个类型的方法用于查询链表中的元素:
get 方法:得到链表中某个位置的元素(位置从 0 开始计数)。该操做在链表中不常使用,能够用来增强链表的理解。具体实现以下:
/** * 得到链表的第 index 个位置的元素 * * @param index 须要获取的元素的位置,从 0 开始计数 * @return 返回链表中的 index 处的元素 */ public E get(int index) { // 判断 index 的合法性 if (index < 0 || index >= size) { throw new IllegalArgumentException("Get failed.Illegal index."); } // 从链表中第一个元素开始遍历,找处处于 index 的节点 Node currentElement = dummyHead.next; for (int i = 0; i < index; i++) { currentElement = currentElement.next; } // 返回处于 index 的元素 return currentElement.element; }
由以上实现可衍生出两个方法分别用来获取链表中第一个元素和链表中最后一个元素:
/** * 得到链表的第一个元素 * * @return 返回链表的第一个元素 */ public E getFirst() { return get(0); } /** * 得到链表的最后一个元素 * * @return 返回链表的最后一个元素 */ public E getLast() { // index 从 0 开始计数,size 为当前元素个数,因此最后一个元素的位置对应为 size - 1 return get(size - 1); }
contains 方法:判断用户给定的一个元素是否存在于链表中,存在返回 true,不存在返回 false。具体实现以下:
/** * 查找链表中是否含有元素 element * * @param element 须要查找的元素 * @return 若是包含 element 返回 true;不然返回 false */ public boolean contains(E element) { // 从链表中第一个元素开始遍历,依次判断是否包含有元素 element Node currentElement = dummyHead.next; while (currentElement != null) { if (currentElement.element.equals(element)) { // 相等说明链表中包含元素 element,返回 true return true; } currentElement = currentElement.next; } // 整个链表遍历完尚未找到则返回 false return false; }
对于此操做,实现的目的是修改链表中某个位置(位置从 0 开始计数)的元素为指定的新元素。该操做在链表中也不常使用,能够用来增强链表的理解。具体实现以下:
/** * 修改链表的第 index 个位置的元素为 newElement * * @param index 须要修改的元素的位置,从 0 开始计数 * @param newElement 替换老元素的新元素 */ public void set(int index, E newElement) { // 判断 index 的合法性 if (index < 0 || index >= size) { throw new IllegalArgumentException("Set failed.Illegal index."); } // 从链表中第一个元素开始遍历,找处处于 index 的节点 Node currentElement = dummyHead.next; for (int i = 0; i < index; i++) { currentElement = currentElement.next; } // 修改 index 的元素为 newElement currentElement.element = newElement; }
对于删除操做,目的为删除链表中某个位置的元素并返回删除的元素。该操做在链表中也不常使用,能够用来增强链表的理解。实现的步骤以下:
找到待删除节点 delNode 的前置节点 prev。
将 prev 的 next 指向待删除节点 dalNode 的 next。即越过了 delNode,和它后面的节点挂接了起来。(prev.next = delNode.next)
将 delNode 的 next 指向 null,至此,delNode 和链表脱离关系,从链表中被删除。(delNode.next = null)
返回 delNode 中存储的元素 element,即返回删除的元素。
删除过程图示以下:
代码具体实现以下:
/** * 从链表中删除 index 位置的节点并返回删除的元素 * * @param index 要删除节点在链表中的位置,从 0 开始计数 * @return 返回删除的元素 */ public E remove(int index) { // 判断 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 delNode = prev.next; // 进行删除操做 prev.next = delNode.next; delNode.next = null; // 维护 size,链表当前元素个数 - 1 size--; // 返回删除的元素 return delNode.element; }
由以上实现可衍生出两个方法分别用于删除链表中的第一个元素和最后一个元素:
/** * 从链表中删除第一个元素所在的节点并返回删除的元素 * * @return 返回删除的元素 */ public E removeFirst() { return remove(0); } /** * 从链表中删除最后一个元素所在的节点并返回删除的元素 * * @return 返回删除的元素 */ public E removeLast() { return remove(size - 1); }
实现到此,已经能够重写 toString 方法显示链表中元素信息来测试以上实现的基本操做了,以此验证设计的逻辑没有出错。
对于此方法,这里设计为下:
/** * 重写 toString 方法,以便观察链表中的元素 * * @return 返回当前链表信息 */ @Override public String toString() { StringBuilder result = new StringBuilder(); result.append(String.format("LinkedList: size = %d, Elements: dummyHead -> ", size)); // 从链表中第一个元素开始遍历,依次将链表中元素信息添加到结果信息中 Node currentElement = dummyHead.next; while (currentElement != null) { result.append(currentElement + " -> "); currentElement = currentElement.next; } // 以上遍历的等价写法: // for (Node currentElement = dummyHead.next; currentElement != null; currentElement = currentElement.next) { // result.append(currentElement + " -> "); // } result.append("NULL"); return result.toString(); }
接着测试以上实现的基本操做,测试代码以下:
/** * 测试 LinkedList */ public static void main(String[] args) { LinkedList<Integer> linkedList = new LinkedList<>(); // 测试 isEmpty 方法 System.out.println("==== 测试 isEmpty 方法 ===="); System.out.println("当前链表是否为空: " + linkedList.isEmpty()); // 测试链表的添加操做 System.out.println("\n==== 测试 addFirst 方法 ===="); for (int i = 0; i < 5; i++) { linkedList.addFirst(i); System.out.println(linkedList); } System.out.println("\n==== 测试 add 方法 ===="); System.out.println("添加 888 到链表中的第 2 个位置(从 0 开始计数): "); linkedList.add(2, 888); System.out.println(linkedList); System.out.println("\n==== 测试 addLast 方法 ===="); linkedList.addLast(999); System.out.println(linkedList); // 测试 contains 方法 System.out.println("\n==== 测试 contains 方法 ===="); System.out.println(linkedList); boolean flag = linkedList.contains(888); System.out.println("链表中是否存在 888: " + flag); flag = linkedList.contains(777); System.out.println("链表中是否存在 777: " + flag); // 测试 get 方法 System.out.println("\n==== 测试 get 方法 ===="); System.out.println(linkedList); Integer element = linkedList.getFirst(); System.out.println("链表中的第一个元素为: " + element); element = linkedList.getLast(); System.out.println("链表中的最后一个元素为: " + element); element = linkedList.get(3); System.out.println("链表中的第 3 个位置(从 0 开始计数)的元素为: " + element); // 测试 isEmpty 方法 System.out.println("\n==== 测试 isEmpty 方法 ===="); System.out.println("当前链表是否为空: " + linkedList.isEmpty()); // 测试 set 方法 System.out.println("\n==== 测试 set 方法 ===="); System.out.println(linkedList); linkedList.set(3, 12); System.out.println("更改链表中的第 3 个位置(从 0 开始计数)的元素为 12 后: "); System.out.println(linkedList); // 测试链表的删除操做 System.out.println("\n==== 测试 remove 方法 ===="); Integer delElement = linkedList.remove(3); System.out.println("删除链表中的第 3 个位置(从 0 开始计数)的元素后: "); System.out.println(linkedList); System.out.println("删除的元素为: " + delElement); System.out.println("\n==== 测试 removeFirst 方法 ===="); delElement = linkedList.removeFirst(); System.out.println("删除链表中的第一个元素后: "); System.out.println(linkedList); System.out.println("删除的元素为: " + delElement); System.out.println("\n==== 测试 removeLast 方法 ===="); delElement = linkedList.removeLast(); System.out.println("删除链表中的最后一个元素后: "); System.out.println(linkedList); System.out.println("删除的元素为: " + delElement); }
测试结果:
==== 测试 isEmpty 方法 ==== 当前链表是否为空: true ==== 测试 addFirst 方法 ==== LinkedList: size = 1, Elements: dummyHead -> 0 -> NULL LinkedList: size = 2, Elements: dummyHead -> 1 -> 0 -> NULL LinkedList: size = 3, Elements: dummyHead -> 2 -> 1 -> 0 -> NULL LinkedList: size = 4, Elements: dummyHead -> 3 -> 2 -> 1 -> 0 -> NULL LinkedList: size = 5, Elements: dummyHead -> 4 -> 3 -> 2 -> 1 -> 0 -> NULL ==== 测试 add 方法 ==== 添加 888 到链表中的第 2 个位置(从 0 开始计数): LinkedList: size = 6, Elements: dummyHead -> 4 -> 3 -> 888 -> 2 -> 1 -> 0 -> NULL ==== 测试 addLast 方法 ==== LinkedList: size = 7, Elements: dummyHead -> 4 -> 3 -> 888 -> 2 -> 1 -> 0 -> 999 -> NULL ==== 测试 contains 方法 ==== LinkedList: size = 7, Elements: dummyHead -> 4 -> 3 -> 888 -> 2 -> 1 -> 0 -> 999 -> NULL 链表中是否存在 888: true 链表中是否存在 777: false ==== 测试 get 方法 ==== LinkedList: size = 7, Elements: dummyHead -> 4 -> 3 -> 888 -> 2 -> 1 -> 0 -> 999 -> NULL 链表中的第一个元素为: 4 链表中的最后一个元素为: 999 链表中的第 3 个位置(从 0 开始计数)的元素为: 2 ==== 测试 isEmpty 方法 ==== 当前链表是否为空: false ==== 测试 set 方法 ==== LinkedList: size = 7, Elements: dummyHead -> 4 -> 3 -> 888 -> 2 -> 1 -> 0 -> 999 -> NULL 更改链表中的第 3 个位置(从 0 开始计数)的元素为 12 后: LinkedList: size = 7, Elements: dummyHead -> 4 -> 3 -> 888 -> 12 -> 1 -> 0 -> 999 -> NULL ==== 测试 remove 方法 ==== 删除链表中的第 3 个位置(从 0 开始计数)的元素后: LinkedList: size = 6, Elements: dummyHead -> 4 -> 3 -> 888 -> 1 -> 0 -> 999 -> NULL 删除的元素为: 12 ==== 测试 removeFirst 方法 ==== 删除链表中的第一个元素后: LinkedList: size = 5, Elements: dummyHead -> 3 -> 888 -> 1 -> 0 -> 999 -> NULL 删除的元素为: 4 ==== 测试 removeLast 方法 ==== 删除链表中的最后一个元素后: LinkedList: size = 4, Elements: dummyHead -> 3 -> 888 -> 1 -> 0 -> NULL 删除的元素为: 999 进程已结束,退出代码 0
从结果能够看出以上实现的基本操做没有出现错误,说明了实现的逻辑是正确的,接下来对以上实现的基本操做作一些简单的时间复杂度分析。
添加操做
addLast 方法:对于该方法,每次都要遍历整个链表进行添加,因此该方法的时间复杂度是 O(n) 级别的。
addFirst 方法:对于该方法,每次都是在链表头部作操做,因此该方法的时间复杂度是 O(1) 级别的。
add 方法:对于该方法,平均来讲,每次添加元素须要遍历 n/2 个元素,因此该方法的时间复杂度是 O(n/2) = O(n) 级别的。
综上,添加操做的时间复杂度为 O(n)。
删除操做
removeLast 方法:对于该方法,每次都要遍历整个链表进行删除,因此该方法的时间复杂度是 O(n) 级别的。
removeFirst 方法:对于该方法,每次都是在链表头部作操做,因此该方法的时间复杂度是 O(1) 级别的。
remove 方法:对于该方法,平均来讲,每次删除元素须要遍历 n/2 个元素,因此该方法的时间复杂度是 O(n/2) = O(n) 级别的。
综上,删除操做的时间复杂度为 O(n)。
修改操做
set 方法:对于该方法,平均来讲,每次修改元素须要遍历 n/2 个元素,因此该方法的时间复杂度是 O(n/2) = O(n) 级别的。
因此修改操做的时间复杂度也为 O(n)。
查找操做
getLast 方法:对于该方法,每次都要遍历整个链表进行查找,因此该方法的时间复杂度是 O(n) 级别的。
getFirst 方法:对于该方法,每次都是在链表头部作操做,因此该方法的时间复杂度是 O(1) 级别的。
get 方法:对于该方法,平均来讲,每次查找元素须要遍历 n/2 个元素,因此该方法的时间复杂度是 O(n/2) = O(n) 级别的。
contains 方法:对于该方法,平均来讲,每次查找判断元素是否存在也是须要遍历 n/2 个元素,因此该方法的时间复杂度也是 O(n/2) = O(n) 级别的。
综上,查找操做的时间复杂度为 O(n)。
因此对于链表而言,增删改查的时间复杂度都是 O(n) 级别的。
可是若是不对链表中元素进行修改操做,添加和删除操做也只针对链表头进行操做和查找操做也只查链表头的元素的话,此时总体的时间复杂度就是 O(1) 级别的了,又因为链表总体是动态的,不会浪费大量的内存空间,此时具备必定的优点,显而易见知足这些条件的数据结构为栈,此时就可使用链表来实现栈发挥链表的优点了。固然,对于链表而言,还有一些改进方式使其在一些应用场景具备优点,好比给链表添加尾指针后使用链表来实现队列。
对于栈这个数据结构,它只针对一端进行操做,即针对栈顶进行操做,是一个后入先出的数据结构。
上文说到若是链表不使用修改操做,只使用添加、删除、查找链表头的操做是知足栈这个数据结构的特色的。因此可使用链表头做为栈顶,用链表做为栈的底层实现来实现栈这个数据结构,发挥链表的动态优点。最后,再和以前基于动态数组实现的栈进行一些效率上的对比,查看二者的差距。接下来,开始实现使用链表实现栈。
对于使用链表实现栈,将使用一个 LinkedListStack 类实现以前实现数组栈时定义的栈的接口 Stack 来实现栈的一系列的操做。
回顾栈的接口 Stack 的实现以下:
/** * 定义栈支持的操做的接口 * 支持泛型 * * @author 踏雪寻梅 * @date 2020/1/8 - 19:20 */ public interface Stack<E> { /** * 获取栈中元素个数 * * @return 栈中若是有元素,返回栈中当前元素个数;栈中若是没有元素返回 0 */ int getSize(); /** * 判断栈是否为空 * * @return 栈为空,返回 true;栈不为空,返回 false */ boolean isEmpty(); /** * 入栈 * 将元素 element 压入栈顶 * * @param element 入栈的元素 */ void push(E element); /** * 出栈 * 将当前栈顶元素出栈并返回 * * @return 返回当前出栈的栈顶元素 */ E pop(); /** * 查看当前栈顶元素 * * @return 返回当前的栈顶元素 */ E peek(); }
对于 LinkedListStack 类的实现,只须要复用链表类中的方法就可实现栈的这些基本操做了,具体实现以下:
/** * 基于 LinkedList 实现的链表栈 * 支持泛型 * * @author 踏雪彡寻梅 * @date 2020/2/5 - 12:22 */ public class LinkedListStack<E> implements Stack<E> { /** * 基于该链表实现栈 */ private LinkedList<E> linkedList; /** * 构造函数 * 构造一个空的链表栈 */ public LinkedListStack() { linkedList = new LinkedList<>(); } @Override public int getSize() { return linkedList.getSize(); } @Override public boolean isEmpty() { return linkedList.isEmpty(); } @Override public void push(E element) { linkedList.addFirst(element); } @Override public E pop() { return linkedList.removeFirst(); } @Override public E peek() { return linkedList.getFirst(); } /** * 重写 toString 方法显示链表栈中的各信息 * * @return 返回链表栈的信息 */ @Override public String toString() { StringBuilder result = new StringBuilder(); result.append(String.format("LinkedListStack: size = %d, top [ ", getSize())); for (int i = 0; i < getSize(); i++) { E e = linkedList.get(i); result.append(e); // 若是不是最后一个元素 if (i != getSize() - 1) { result.append(", "); } } result.append(" ] bottom"); return result.toString(); } }
接下来,对以上实现作一些测试,检测是否和预期结果不符,测试代码以下:
/** * 测试 LinkedListStack */ public static void main(String[] args) { LinkedListStack<Integer> stack = new LinkedListStack<>(); // 判断栈是否为空 System.out.println("==== 测试 isEmpty ===="); System.out.println("当前栈是否为空: " + stack.isEmpty()); System.out.println("\n==== 测试链表栈的入栈,入栈 10 次 ===="); for (int i = 0; i < 10; i++) { // 入栈 stack.push(i); // 打印入栈过程 System.out.println(stack); } System.out.println("\n==== 测试链表栈的出栈,出栈 1 次 ===="); // 进行一次出栈 stack.pop(); // 查看出栈后的状态 System.out.println(stack); // 查看当前栈顶元素 System.out.println("\n==== 测试链表栈的查看栈顶元素 ===="); Integer topElement = stack.peek(); System.out.println("当前栈顶元素: " + topElement); // 判断栈是否为空 System.out.println("\n==== 测试 isEmpty ===="); System.out.println("当前栈是否为空: " + stack.isEmpty()); }
测试结果:
==== 测试 isEmpty ==== 当前栈是否为空: true ==== 测试链表栈的入栈,入栈 10 次 ==== LinkedListStack: size = 1, top [ 0 ] bottom LinkedListStack: size = 2, top [ 1, 0 ] bottom LinkedListStack: size = 3, top [ 2, 1, 0 ] bottom LinkedListStack: size = 4, top [ 3, 2, 1, 0 ] bottom LinkedListStack: size = 5, top [ 4, 3, 2, 1, 0 ] bottom LinkedListStack: size = 6, top [ 5, 4, 3, 2, 1, 0 ] bottom LinkedListStack: size = 7, top [ 6, 5, 4, 3, 2, 1, 0 ] bottom LinkedListStack: size = 8, top [ 7, 6, 5, 4, 3, 2, 1, 0 ] bottom LinkedListStack: size = 9, top [ 8, 7, 6, 5, 4, 3, 2, 1, 0 ] bottom LinkedListStack: size = 10, top [ 9, 8, 7, 6, 5, 4, 3, 2, 1, 0 ] bottom ==== 测试链表栈的出栈,出栈 1 次 ==== LinkedListStack: size = 9, top [ 8, 7, 6, 5, 4, 3, 2, 1, 0 ] bottom ==== 测试链表栈的查看栈顶元素 ==== 当前栈顶元素: 8 ==== 测试 isEmpty ==== 当前栈是否为空: false 进程已结束,退出代码 0
从结果能够看出,实现的结果和预期是相符的,实现了栈的各个基本操做。总体的时间复杂度前面也分析过了,都是对链表头部进行操做,时间复杂度是 O(1) 级别的,而且拥有了链表的总体动态性。接下来和以前实现的数组栈进行一些效率上的对比:
测试代码:
import java.util.Random; /** * 对比数组栈和链表栈的效率差距 * * @author 踏雪彡寻梅 * @date 2020/2/5 - 12:52 */ public class Main { /** * 测试使用 stack 运行 opCount 个 push 和 pop 操做所须要的时间,单位: 秒 * * @param stack 测试使用的栈 * @param opCount 测试的数量级 * @return 返回测试的运行时间,单位: 秒 */ 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 = 10000; 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"); } }
测试结果-1(opCount 为 1 万时)
测试结果-2(opCount 为 10 万时)
测试结果-3(opCount 为 100 万时)
测试结果-4(opCount 为 1000 万时)
从这几个测试结果能够看出在我这台机器上当数据量较小时,链表栈耗时比数组栈短,随着数据量增大,数组栈耗时比链表栈短。
但归根结底,这两个栈的入栈和出栈操做的时间复杂度是同一级别 O(1) 的。
这种有时快有时慢的状况主要跟内部实现相关:
对于数组栈来讲可能时不时须要从新分配数组空间进行扩容或者减容,这一操做会消耗一些时间。
对于链表栈来讲则是有不少 new 新节点 Node 的操做,这些 new Node 的操做也会消耗一些时间。
因此这两种栈之间的时间对比会出现这种常数倍的差别,属于正常的状况,它们之间没有复杂度上的巨大的差别,整体上的时间复杂度仍是同一级别的,具体的时间差别最多也就是几倍的差别,不会产生巨大的差别。
固然,相比数组栈须要时不时从新分配数组空间达到动态伸缩容量的目的,链表栈的动态性将会显得更有优点一些,不须要咱们像数组栈同样手动地进行伸缩容量处理。
对于队列这个数据结构,它是针对两端进行操做,即针对队首和队尾进行操做,是一个先入先出的数据结构。
而在以前的链表实现中,只有针对链表头的操做是 O(1) 级别的,那么若是用来实现队列这种数据结构的话,就会有一端的操做是 O(n) 级别的,为了解决这个问题,能够给链表添加一个尾指针 tail 用于追踪链表尾部,达到对链表首尾两端的操做都是 O(1) 级别进而实现队列的目的。
当给链表添加尾指针 tail 后,能够发如今尾部删除元素是须要从头遍历找到前置节点的进行删除操做的,这个过程是 O(n) 的,不知足咱们的需求;而若是在尾部添加元素的话,就和在链表头添加元素一个道理,是十分方便的,只须要 O(1) 的复杂度。再看回链表头,由以前的实现能够发如今头部删除元素很是方便,只须要 O(1) 的复杂度。
因此能够设计为在链表头进行删除元素的操做,在链表尾进行添加元素的操做。即将链表头做为队首,链表尾做为队尾。
因为在队列中只须要对两端进行操做,因此这里实现队列时就不复用前面实现的链表类了。在以前实现的链表类中,设计了虚拟头节点便于统一操做链表中的全部数据。而在如今的队列实现中只须要在头部删除元素在尾部添加元素,因此不须要使用虚拟头节点。只须要两个变量 head、tail 分别指向链表中的第一个元素和链表中的最后一个非 NULL 元素便可。
须要注意的是这样设计后当队列为空时,head 和 tail 都指向 NULL。
改进后的链表队列基本结构以下图所示:
和以前实现栈同样,这里也是实现一个 LinkedListQueue 类实现以前实现数组队列时定义的队列的接口 Queue 来实现队列的一系列的操做。
回顾队列的接口 Queue 的实现以下:
/** * 定义队列支持的操做的接口 * 支持泛型 * * @author 踏雪寻梅 * @date 2020/1/9 - 16:52 */ public interface Queue<E> { /** * 获取队列中元素个数 * * @return 队列中若是有元素,返回队列中当前元素个数;队列中若是没有元素返回 0 */ int getSize(); /** * 判断队列是否为空 * * @return 队列为空,返回 true;队列不为空,返回 false */ boolean isEmpty(); /** * 入队 * 将元素 element 添加到队尾 * * @param element 入队的元素 */ void enqueue(E element); /** * 出队 * 将队首的元素出队并返回 * * @return 返回当前出队的队首的元素 */ E dequeue(); /** * 查看当前队首元素 * * @return 返回当前的队首元素 */ E getFront(); }
对于 LinkedListQueue 类的实现,具体实现以下:
/** * 链表队列 * 支持泛型 * * @author 踏雪彡寻梅 * @date 2020/2/5 - 14:45 */ public class LinkedListQueue<E> implements Queue<E> { /** * 链表的节点 * 对于用户而言,不须要知道链表的底层结构是怎样的,只须要知道链表是一种线性数据结构,能够增删改查数据 */ private class Node { /** * 节点存储的数据 */ public E element; /** * 用于指向下一个节点,使节点与节点之间挂接起来组成链表 */ public Node next; /** * 构造函数 * 构造一个存有数据并指向了下一个节点的节点 * * @param element 存往该节点中的数据 * @param next 该节点的下一个节点 */ public Node(E element, Node next) { this.element = element; this.next = next; } /** * 构造函数 * 构造一个存有数据但没有指向下一个节点的节点 * * @param element 存往该节点中的数据 */ public Node(E element) { this(element, null); } /** * 构造函数 * 构造一个空节点 */ public Node() { this(null, null); } /** * 重写 toString 方法以显示节点中存储的数据信息 * * @return 返回节点中存储的数据信息 */ @Override public String toString() { return element.toString(); } } /** * 用于指向链表队列的第一个节点 */ private Node head; /** * 用于指向链表队列的最后一个非 NULL 节点 */ private Node tail; /** * 链表队列当前元素个数 */ private int size; /** * 构造函数 * 构造一个空的链表队列 */ public LinkedListQueue() { // 链表队列为空时, head 和 tail 都指向 null head = null; tail = null; size = 0; } @Override public int getSize() { return size; } @Override public boolean isEmpty() { return size == 0; } @Override public void enqueue(E element) { if (tail == null) { // 空队时入队 tail = new Node(element); head = tail; } else { // 非空队时入队 tail.next = new Node(element); tail = tail.next; } // 维护 size,队列当前元素个数 + 1 size++; } @Override public E dequeue() { // 出队时判断队列是否为空 if (isEmpty()) { throw new IllegalArgumentException("Dequeue failed. Cannot dequeue from an empty queue."); } // 记录要出队的节点 Node dequeueNode = head; // 将队头节点出队 head = head.next; dequeueNode.next = null; // 若是出队后队列为空,维护 tail 指向 null,空队时 head 和 tail 都指向 null if (head == null) { tail = null; } // 维护 size,队列当前元素个数 - 1 size--; // 返回出队元素 return dequeueNode.element; } @Override public E getFront() { // 获取队头元素时判断队列是否为空 if (isEmpty()) { throw new IllegalArgumentException("GetFront failed. Queue is empty."); } // 返回队头元素 return head.element; } /** * 重写 toString 方法显示链表队列的详细信息 * * @return 返回链表队列的详细详细 */ @Override public String toString() { StringBuilder result = new StringBuilder(); result.append(String.format("LinkedListQueue: size: %d, front [ ", getSize())); // 从链表中第一个元素开始遍历,依次将链表中元素信息添加到结果信息中 Node currentElement = head; while (currentElement != null) { result.append(currentElement + "->"); currentElement = currentElement.next; } result.append("NULL ] tail"); return result.toString(); } }
在实现中须要注意的是在入队时若是是空队列须要维护 head 指向 tail,不然 head 会指向 null。以及在出队时须要判断出队后队列是否为空,若是为空须要维护 tail 指向 null。以及须要注意队列为空时 head 和 tail 都指向 null。
接下来,对以上实现作一些测试,检测是否和预期结果不符,测试代码以下:
/** * 测试 LinkedListQueue */ public static void main(String[] args) { LinkedListQueue<Integer> queue = new LinkedListQueue<>(); // 判断队列是否为空 System.out.println("==== 测试 isEmpty ===="); System.out.println("当前队列是否为空: " + queue.isEmpty()); System.out.println("\n==== 测试入队和出队, 10 次 入队, 每 3 次入队就出队 1 次===="); for (int i = 0; i < 10; i++) { // 入队 queue.enqueue(i); // 显示入队过程 System.out.println(queue); // 每入队 3 个元素就出队一次 if (i % 3 == 2) { // 出队 queue.dequeue(); // 显示出队过程 System.out.println("\n" + queue + "\n"); } } // 判断队列是否为空 System.out.println("\n==== 测试 isEmpty ===="); System.out.println("当前队列是否为空: " + queue.isEmpty()); // 获取队首元素 System.out.println("\n==== 测试 getFront ===="); System.out.println(queue); Integer front = queue.getFront(); System.out.println("当前队列队首元素为: " + front); }
测试结果:
==== 测试 isEmpty ==== 当前队列是否为空: true ==== 测试入队和出队, 10 次 入队, 每 3 次入队就出队 1 次==== LinkedListQueue: size: 1, front [ 0->NULL ] tail LinkedListQueue: size: 2, front [ 0->1->NULL ] tail LinkedListQueue: size: 3, front [ 0->1->2->NULL ] tail LinkedListQueue: size: 2, front [ 1->2->NULL ] tail LinkedListQueue: size: 3, front [ 1->2->3->NULL ] tail LinkedListQueue: size: 4, front [ 1->2->3->4->NULL ] tail LinkedListQueue: size: 5, front [ 1->2->3->4->5->NULL ] tail LinkedListQueue: size: 4, front [ 2->3->4->5->NULL ] tail LinkedListQueue: size: 5, front [ 2->3->4->5->6->NULL ] tail LinkedListQueue: size: 6, front [ 2->3->4->5->6->7->NULL ] tail LinkedListQueue: size: 7, front [ 2->3->4->5->6->7->8->NULL ] tail LinkedListQueue: size: 6, front [ 3->4->5->6->7->8->NULL ] tail LinkedListQueue: size: 7, front [ 3->4->5->6->7->8->9->NULL ] tail ==== 测试 isEmpty ==== 当前队列是否为空: false ==== 测试 getFront ==== LinkedListQueue: size: 7, front [ 3->4->5->6->7->8->9->NULL ] tail 当前队列队首元素为: 3 进程已结束,退出代码 0
从结果能够看出,实现的结果和预期是相符的,实现了队列的各个基本操做。总体的时间复杂度前面也简单分析过了,针对链表头部和尾部进行操做,时间复杂度都是 O(1) 级别的,而且拥有了链表的总体动态性。接下来和以前实现的数组队列和循环队列进行一些效率上的对比:
测试代码:
import java.util.Random; /** * 测试 ArrayQueue、LoopQueue 和 LinkedListQueue 的效率差距 * * @author 踏雪寻梅 * @date 2020/1/8 - 16:49 */ public class Main2 { public static void main(String[] args) { // 测试数据量 int opCount = 10000; // 测试数组队列所须要的时间 ArrayQueue<Integer> arrayQueue = new ArrayQueue<>(); double arrayQueueTime = testQueue(arrayQueue, opCount); System.out.println("arrayQueueTime: " + arrayQueueTime + " s."); // 测试循环队列所须要的时间 LoopQueue<Integer> loopQueue = new LoopQueue<>(); double loopQueueTime = testQueue(loopQueue, opCount); System.out.println("loopQueueTime: " + loopQueueTime + " s."); // 测试链表队列所须要的时间 LinkedListQueue<Integer> linkedListQueue = new LinkedListQueue<>(); double linkedListQueueTime = testQueue(linkedListQueue, opCount); System.out.println("linkedListQueueTime: " + linkedListQueueTime + " s."); } /** * 测试使用队列 queue 运行 opCount 个 enqueue 和 dequeue 操做所须要的时间,单位: 秒 * @param queue 测试的队列 * @param opCount 测试的数据量 * @return 返回整个测试过程所须要的时间,单位: 秒 */ private static double testQueue(Queue<Integer> queue, int opCount) { long startTime = System.nanoTime(); // 用于生成随机数入队 Random random = new Random(); // opCount 次 enqueue for (int i = 0; i < opCount; i++) { // 入队 queue.enqueue(random.nextInt(Integer.MAX_VALUE)); } // opCount 次 dequeue for (int i = 0; i < opCount; i++) { // 出队 queue.dequeue(); } long endTime = System.nanoTime(); // 将纳秒单位的时间转换为秒单位 return (endTime - startTime) / 1000000000.0; } }
测试结果-1(opCount 为 1 万时)
测试结果-2(opCount 为 10 万时)
测试结果-3(opCount 为 100 万时)
从以上几种结果能够看出,在我这台机器上数组队列耗时比基于数组的循环队列和链表队列要大的多,基于数组的循环队列耗时和链表队列相差不大,是常数倍的差别。
对于数组队列而言它的入队操做是 O(1) 级别的、出队操做是 O(n) 级别的,因此在以上测试中总体时间复杂度是 O(n2) 级别的(进行了 n 次入队和出队)。
而基于数组的循环队列和链表队列的入队操做和出队操做都是 O(1) 级别的,因此在以上测试中总体而言时间复杂度是 O(n) 级别的(都进行了 n 次入队和出队)。但这二者有时候也会出现一个快一点一个慢一点的状况,也是和前面的链表栈和数组栈的状况是同样的,这里再也不阐述。
总而言之基于数组实现的循环队列和链表队列这二者是同一级别的复杂度的,相比数组队列的时间复杂度快了不少,时间上的差别是巨大的。
最后,链表队列在动态性上相比基于数组实现的循环队列会更好一些,不须要手动进行伸缩容量的实现。
实现到此处,链表的常见基本操做也都实现完成了,对于链表而言,也还存在着一些改进方案,好比给节点增长一个前置指针域用于指向当前节点的前置节点,使链表变成双链表等等。这里就再也不实现了,具体过程仍是大同小异的。
链表是一种真正的动态的数据结构,不须要像数组同样手动地处理动态伸缩容量。
链表在针对头部和尾部作特殊处理后,能够实现栈和队列这两种数据结构,极大地发挥了链表的动态特性。
若有写的不足的,请见谅,请你们多多指教。