各位很久不见啊,因为疫情缘由笔者一直宅在家中作考研复习。俗语云:聚沙成塔,跬步千里。因而我在此作一个简单分享,一步步记录个人学习历程。java
道家有言:一辈子二,二生三,三生万物
,万物皆有源头,在说双向链表以前让咱们先看看单链表
吧。node
咱们在学习计算机编程语言时,最早接触的数据结构是线性表
,线性表是逻辑结构,其根据存储方式的不一样,又分为 顺序表
,链表
。而 单链表
是链表中最基础的结构。算法
以下图所示,编程
其中,咱们有两个节点,第一个节点的值为10,并拥有一个指针指向下一个节点15。缓存
可能的类代码:数据结构
public class SLList { private IntNode first; public SLList() { first = null; } public SLList(int x) { first = new IntNode(x, null); } public void addFirst(int x) { first = new IntNode(x, first); } public int getFirst() { return first.item; } }
在上面的单链表中,咱们实现了从头结点插入的功能,若是咱们要实现从链表的尾部插入的功能呢?编程语言
咱们可能会这样写:学习
public void addLast(int x) { size += 1; IntNode p = first; while (p.next != null) { p = p.next; } p.next = new IntNode(x, null); }
可是,若是咱们要插入到一个空链表时,由于 first自己是 null
,当咱们运行到 while(p.next != null)
时,程序会发生错误!this
有的同窗就会想到,那咱们加一个 if
处理不就好了。操作系统
if (first == null) { first = new IntNode(x, null); return; } while(p.next != null){ p = p.next; } p.next = new IntNode(x,null);
可是,这样处理问题会显得不美观。并且当你处理的特殊状况愈来愈多的时候,你的代码会愈来愈长,致使难以阅读和维护,并破坏了简单设计的原则。
这个时候咱们的大救星,哨兵节点
,闪亮登场。
如上图所示,咱们在初始化空链表时,会建立一个哨兵节点
,他不存储值,只是提供了一个守门员的角色,帮助你看看门外有没有人并帮助你寻找后面的节点。咱们把它叫作 sentinel
。
这样咱们就不用担忧会遇到空节点的状况,万岁。事情变得简单和规范化了,没有特殊例子!
咱们能够这样写代码了,去掉了 if
语句:
IntNode p = sentinel; while (p.next != null) { p = p.next; } p.next = new IntNode(x,null);
咱们解决了从头部插入和从尾部插入的问题,可是若是咱们要删除最后一个节点呢?时间复杂度是多少?
显然,咱们要从头节点,一直找下去,直到导数第二个节点,时间复杂度为 O(n)
。有没有办法缩短期呢?
若是咱们想要删除最末尾的节点,显然咱们要找到最后的节点和倒数第二个节点,因此咱们能够添加一个指向上一个节点的指针。并添加指向最末尾的指针,一直指向最后一个节点。
这样的结构够好么?别忘了还有咱们的哨兵朋友们!
最后综合上述缘由,咱们造出了带有哨兵节点的双向链表!以下图所示:
上面咱们讲了双向链表
的由来,这里咱们正式实现双向链表:
API:
public class DLList<T> { // 使用了泛型实现双向链表 private TNode sentinel; private int size; // 新建内部类,节点 public class TNode{ TNode prev; TNode next; T item; public TNode(T item,TNode prev,TNode next){ this.item = item; this.prev = prev; this.next = next; } } // 新建空链表 public DLList(){ sentinel = new TNode(null,null,null); sentinel.prev = sentinel.next = sentinel; size = 0; } public void addFirst(T item){ TNode newNode = new TNode(item,sentinel,sentinel.next); sentinel.next.prev = newNode; sentinel.next = newNode; size+=1; } public boolean validateIndex(int index){ if(index<0||index>=size){ return false; } return true; } /* * helper method to get the node we need * */ private TNode getNode(int index){ TNode res; if(index<size/2){ res = sentinel.next; for (int i=0;i<index;i++){ res = res.next; } return res; } res = sentinel.prev; int newIndex = size - index -1; for (int i = 0 ;i<newIndex;i++){ res = res.prev; } return res; } public T get(int index){ if(!validateIndex(index)) return null; return getNode(index).item; } public int size(){ return size; } public boolean isEmpty(){ return size==0; } public void addLast(T item){ TNode newNode = new TNode(item,sentinel.prev,sentinel); sentinel.prev.next = newNode; sentinel.prev = newNode; size+=1; } /* * helper method to delete the node we want * */ private T delete(int index){ if(!validateIndex(index)) throw new IndexOutOfBoundsException(); TNode cur = getNode(index); T res = cur.item; cur.prev.next = cur.next; cur.next.prev = cur.prev; cur = null; size--; return res; } public T removeLast(){ return delete(size-1); } public T removeFirst(){ return delete(0); } }
学习过计算机操做系统的小伙伴,必定知道咱们管理内存时须要页面置换算法。其中一种经典的算法就是LRU
算法(最近最久未使用算法)。
利用双向链表,咱们能够软件模拟这种操做。每次使用数据,或者插入新数据的时候,咱们把它移动到头部。
这样越靠近头部的就是咱们常用的数据。而当数据满了的时候,咱们只要删除尾部的节点就行了,由于他是最久未使用的数据。
众所周知,链表的遍历是线性的,当咱们要查询数据的时候,速度并不理想。因而咱们引入哈希表
加速查找。
LRU 缓存机制能够经过哈希表辅以双向链表实现,咱们用一个哈希表和一个双向链表维护全部在缓存中的键值对。
双向链表按照被使用的顺序存储了这些键值对,靠近头部的键值对是最近使用的,而靠近尾部的键值对是最久未使用的。
哈希表即为普通的哈希映射(HashMap),经过缓存数据的键映射到其在双向链表中的位置。
这样一来,咱们首先使用哈希表进行定位,找出缓存项在双向链表中的位置,随后将其移动到双向链表的头部,便可在 O(1)
, O(1)
的时间内完成 get
或者 put
操做。具体的方法以下:
对于 get 操做,首先判断 key 是否存在:
若是 key 不存在,则返回 -1−1;
若是 key 存在,则 key 对应的节点是最近被使用的节点。经过哈希表定位到该节点在双向链表中的位置,并将其移动到双向链表的头部,最后返回该节点的值。
对于 put 操做,首先判断 key 是否存在:
若是 key 不存在,使用 key 和 value 建立一个新的节点,在双向链表的头部添加该节点,并将 key 和该节点添加进哈希表中。而后判断双向链表的节点数是否超出容量,若是超出容量,则删除双向链表的尾部节点,并删除哈希表中对应的项;
若是 key 存在,则与 get 操做相似,先经过哈希表定位,再将对应的节点的值更新为 value,并将该节点移到双向链表的头部。
上述各项操做中,访问哈希表的时间复杂度为 O(1)O(1),在双向链表的头部添加节点、在双向链表的尾部删除节点的复杂度也为 O(1)O(1)。而将一个节点移到双向链表的头部,能够分红「删除该节点」和「在双向链表的头部添加节点」两步操做,均可以在 O(1)O(1) 时间内完成。
代码以下:
public class LRUCache { class DLinkedNode { int key; int value; DLinkedNode prev; DLinkedNode next; public DLinkedNode() {} public DLinkedNode(int _key, int _value) {key = _key; value = _value;} } private Map<Integer, DLinkedNode> cache = new HashMap<Integer, DLinkedNode>(); private int size; private int capacity; private DLinkedNode head, tail; public LRUCache(int capacity) { this.size = 0; this.capacity = capacity; // 使用伪头部和伪尾部节点 head = new DLinkedNode(); tail = new DLinkedNode(); head.next = tail; tail.prev = head; } public int get(int key) { DLinkedNode node = cache.get(key); if (node == null) { return -1; } // 若是 key 存在,先经过哈希表定位,再移到头部 moveToHead(node); return node.value; } public void put(int key, int value) { DLinkedNode node = cache.get(key); if (node == null) { // 若是 key 不存在,建立一个新的节点 DLinkedNode newNode = new DLinkedNode(key, value); // 添加进哈希表 cache.put(key, newNode); // 添加至双向链表的头部 addToHead(newNode); ++size; if (size > capacity) { // 若是超出容量,删除双向链表的尾部节点 DLinkedNode tail = removeTail(); // 删除哈希表中对应的项 cache.remove(tail.key); --size; } } else { // 若是 key 存在,先经过哈希表定位,再修改 value,并移到头部 node.value = value; moveToHead(node); } } private void addToHead(DLinkedNode node) { node.prev = head; node.next = head.next; head.next.prev = node; head.next = node; } private void removeNode(DLinkedNode node) { node.prev.next = node.next; node.next.prev = node.prev; } private void moveToHead(DLinkedNode node) { removeNode(node); addToHead(node); } private DLinkedNode removeTail() { DLinkedNode res = tail.prev; removeNode(res); return res; } }
引用:
^1链表定义
^2缓存文件置换机制
^3leetcode