不定期补充、修正、更新;欢迎大家讨论和指正
本文以数据结构(C语言版)第三版 李云清 杨庆红编著为主要参考资料,用Java来实现
数据结构与算法Java(一)——线性表
数据结构与算法Java(二)——字符串、矩阵压缩、递归
- 数据结构与算法Java(三)——树
数据结构与算法Java(四)——检索算法
数据结构与算法Java(五)——图
数据结构与算法Java(六)——排序算法
基本概念
数据结构(Data Structure)是计算机存储、组织数据的方式。数据结构是指相互之间存在一种或多种特定关系的数据元素的集合。通常情况下,精心选择的数据结构可以带来更高的运行或者存储效率。数据结构往往同高效的检索算法和索引技术有关。
算法(Algorithm)是指解题方案的准确而完整的描述,是一系列解决问题的清晰指令,算法代表着用系统的方法描述解决问题的策略机制。也就是说,能够对一定规范的输入,在有限时间内获得所要求的输出。如果一个算法有缺陷,或不适合于某个问题,执行这个算法将不会解决这个问题。不同的算法可能用不同的时间、空间或效率来完成同样的任务。一个算法的优劣可以用空间复杂度与时间复杂度来衡量。
简而言之,数据结构研究的是数据的逻辑结构,存储方式,以及运算集合。而算法是解决一个问题的方法和步骤。现在具体展开来了解:
逻辑结构:逻辑结构是数据与数据之间所存在的逻辑关系,在不引起误解的情况下,会把数据的逻辑结构简称为数据结构。例如有a、b、c、d四个人,前者依次是后者的父亲,即 a-> b-> c-> d。如果只讨论他们之间存在的父子关系,可以用下面的二元组形式化予表示:
B = (K,R)
其中,K = {a,b,c,d}, R = {<a,b>, <b,c>, <c,d>}
a为b的前驱结点,b为a的后继结点。
这就是数据与数据之间的逻辑关系,常见且十分重要的逻辑结构有三种:线性结构、树形结构、图形结构。这都是我们后续学习的重点,本文只涉及线性结构。
-
线性结构:该逻辑结构只有一个开始结点和一个终端结点,而其他每个结点有且仅有一个前驱结点和后继结点。
线性结构根据对结点的操作方式不同又可以分为三个重要的结构:-
线性表(list):无操作限制。
-
栈(stack):栈是特殊的线性表,规定它的插入运算(入栈)和删除运算(出栈)均在同一端进行,进行运算的一端称为栈顶,另一端称为栈底,因此栈也可以叫FILO(First In Last Out,先进后出)。
-
队列(queue):队列是一种特殊的线性表,它只允许在表的前端(front)进行删除操作,而在表的后端(rear)进行插入操作。进行插入操作的端称为队尾,进行删除操作的端称为队头。队列也称为FIFO(First In First Out,先进先出)
-
-
树形结构:树形结构仅有一个开始结点,有多个终端结点,除开始结点外,每个结点有且仅有一个前驱结点,但可以有多个后继结点。
-
图形结构:如果每个结点都可以有多个前驱和后继结点,该结构为图形结构
存储结构:数据的逻辑结构是独立于计算机的,它与数据在计算机中的存储无关。要对数据进行处理,就必须将数据存储在计算机中,数据在计算机中的存储方式就是存储结构。数据的存储结构主要分为四种
- 顺序存储:将逻辑上相邻的结点存储在计算机上连续存储区域的相邻存储单元中,数组就是顺序存储,常用于存储具有线性结构的数据。
- 链式存储:链式存储是在每个结点中附加一个或多个指针域,用于指向其他结点的物理地址。这样就可以让逻辑上相邻的结点在计算机上可以不相邻
- 索引存储:线性结构中,设开始结点的索引号为1,其他结点的索引号等于其前驱结点的索引号加1,则每一个结点都有唯一的索引号。索引存储就是根据结点的索引号确定该结点的存储地址。例如,一本书的目录就是各章节的索引,目录中每个章节后面标识的页码就是该章节在书中的位置。如果某本书的每个章节所占页码总数相同,那么可以由一个线性函数来确定每个章节在书中的位置。
- 散列存储:散列存储(哈希表),是将每个元素通过一定的散列函数(哈希函数)计算得到其应该存放的位置,当在散列表中想要找到该元素,只需要同样的函数计算就可以找到其存放的位置。
算法需要满足以下五个特征
- 有穷性。算法的执行必须在有限步内结束。
- 确定性。算法的每一个步骤必须是确定的,无二义性的。
- 输入。算法可以有0个或多个输入。
- 输出。算法一定有输出结果
- 可行性。算法中的运算都必须是可以实现的
衡量一个算法的优劣主要从算法执行时间(时间复杂度)和所需要占用的存储空间(空间复杂度)两个方面来衡量,但更多还是关注时间度。
时间复杂度的衡量不是采用算法执行的决定时间来计算的,毕竟一个算法在不同计算机上执行所花的时间也不尽相同,所以时间复杂度是根据算法执行过程中其基本操作的执行次数来衡量的。
对于结点个数为n的数据处理问题,算法中利用T(n)表示算法基本操作的执行次数。为评价算法的时间复杂度与空间复杂度,引入记号’O’。
设T(n)和f(n)是定义在正整数集合上的两个函数,如果存在正常数C和m,使得当n≥m时都有0≤T(n)≤C·f(n),则记作T(n) = O(f(n))。
比较算法的时间性能时,主要看的是所处数量级如何,例如一个算法执行基本操作时间复杂度为T1(n) = 2n,另一个为T2(n) = n+1。
由于T1(n) = O(n),T2(n) = O(n),所以它们被视为相同时间复杂度的算法,也就是说在评价算法时,不考虑算法执行次数之间的细小区别,而只关心本质差别。
时间复杂度不仅和问题的规模有关,还与问题数据的初始状态有关,比如普通数组检索问题,如果检索元素刚好是第一个,则检索时间为O(1),如果在最后,则检索时间为O(n)。因此研究时间复杂度,更加关心的是平均情况下的时间复杂度(指在所有可能的情况下的计算量经过加权计算出的平均值)和最坏情况下的时间复杂度。
算法按数量级递增排列,常见算法的时间复杂度有以下几种,算法复杂度越小越好。例如后面学习的排序算法,冒泡排序的时间复杂度为O(n²),快速排序为O(nlog2(n)),对于同样8个元素排序,冒泡排序的复杂度为8*8=64,快速排序只需要24次(都是最坏情况下)。
顺序存储
顺序存储是所有的结点元素存放在一块连续的存储区域中,用存储结点的物理位置来体现结点之间的逻辑关系的存储方法。在高级语言中,一块连续的存储空间通常可用一个数组来表示。因此,顺序存储通常用一个数据元素类型的数组来存储。最经典的顺序存储结构是顺序表,将线性结构的元素按序存放在一个数组中 。下面会用顺序存储依次实现线性结构的线性表、栈、队列。
顺序表
类结构
public class SequenceList {
public static final int MAXSIZE = 10;//顺序表最大容量
private int[] list = new int[MAXSIZE];
private int size = 0;//当前顺序表有效元素个数
}
以下为顺序表的操作集,用(⭐)标识的功能会列出代码实现,其他的功能实现或原理都较简单,自行进行实现。后面所有文章的操作集也采用这种形式
操作集
- void reset();重置顺序表
- void append(int data);向顺序表末尾添加新元素
- void display();打印顺序表
- boolean isEmpty();判断顺序表是否为空
- boolean isFull();判断顺序表是否满了
- int find(int data);找出data在顺序表的位置下标
- int getSize();size的get方法
- int get(int position);获取postition位置的值
- void insert(int position, int data);向position位置插入data新元素。(⭐)
- void delete(int position);删除position位置元素。(⭐)
顺序表的插入操作如图所示,首先先把需要插入的位置空出来,这就需要将插入位置后续的结点一一往后移。并且后移需要从尾结点开始移动,给其前驱结点腾出位置。如果从插入位置的结点开始移动,就会覆盖其后继结点,从而导致数据出错。
insert
public void insert(int position, int data){
if(isFull()){
System.out.println("the list is full,can`t insert the data");
return;
}
if(position<0||position>=MAXSIZE){
System.out.println("the position is illegal");
return;
}
for (int i = size; i>=position; i--){
list[i] = list[i-1];
}
list[position] = data;
this.size++;
}
删除操作开始位置与插入操作刚好相反,需要从删除位置的后继结点开始,将后面的结点一一往前移,如果从尾结点开始前移,同样会发生数据覆盖的问题。
delete
public void delete(int position){
if(isEmpty()){
System.out.println("the list is empty,can`t delete the data");
return;
}
if(position<0||position>=MAXSIZE){
System.out.println("the position is illegal");
return ;
}
for (int i = position; i<size-1; i++){
list[i] = list[i+1];
}
this.size--;
}
顺序栈
类结构
public class SequenceStack {
public static final int MAXSIZE = 10;
private int[] stack = new int[MAXSIZE];
private int top = 0;//栈的顶点指针,功能同顺序表的size
}
操作集
- void reset()
- void display()
- boolean isEmpty()
- boolean isFull()
- int get();获取栈顶点的数据
- void push(int data);向栈传入data元素。(⭐)
- int pop();拿出栈顶的元素并返回。(⭐)
- boolean matchBrackets(char[] bracketList);括号匹配问题(⭐)
push
public void push(int data){
if(isFull()){
System.out.println("the stack is full,can`t push the data");
return;
}
stack[this.top] = data;
this.top++;
}
pop
public int pop(){
if(isEmpty()){
System.out.println("the stack is empty");
return -999;
}
return stack[--top];
}
在Java中,Stack类可以实现栈的功能
括号匹配问题
设一个表达式中可以包含三种括号:小、中、大括号,各种括号之间允许任意嵌套,但不能交叉,如下
([]{}) 匹配
([()]) 匹配
{(}[]) 不匹配
如何检验一个表达式的括号是否匹配呢?当自左向右扫描时,每遇到的开括号(左括号),都期望有一个闭括号(右括号)与之匹配,按照括号正确匹配的规则,后遇到的开括号比先遇到的开括号更期望有一个闭括号与之匹配。因为可能会遇到多个开括号,所以需要将遇到开括号存放好,当遇到一个闭括号时,将最后遇到的开括号进行匹配,如果他们匹配就可以将此开括号移除,如果不匹配,说明表达式中的括号不论什么情况都已经是交叉,不匹配了。因为最先遇到的开括号最后才进行匹配,最后遇到的开括号先匹配,具有先进后出的特点,所以可以用栈来实现。
public boolean matchBrackets(char[] bracketList){
int i = 0;
while (bracketList[i] != '#'){
switch (bracketList[i]){
case '(':
case '[':
case '{':
push(bracketList[i]);//遇到开括号就入栈
break;
case ')':
if(get()=='('){//遇到闭括号,与最后遇到的开括号进行匹配,匹配成功将栈顶开括号出栈,不匹配的话整个表达式就不可能匹配了,直接返回
pop();
break;
}
else
return false;
case ']':
if(get()=='['){
pop();
break;
}
else
return false;
case '}':
if(get()=='{'){
pop();
break;
}
else
return false;
}
i++;
}
return (isEmpty());//最后判断栈是否为空,因为有可能开括号没有相应的闭括号进行匹配
}
后缀表达式
后缀表达式,也叫逆波兰表达式求值,我们平常使用的算式是一种中缀表达式,如 ( 1 + 2 ) * ( 3 + 4 ) 。
算式如果用逆波兰表达式写法则为 ( ( 1 2 + ) ( 3 4 + ) * ) ,在后缀表达式中去掉括号并不会产生歧义,也就是说如果表达式是1 2 + 3 4 + *得到结果也是一样的
后缀表达式是从左向右顺序扫描中,当遇到数字时不做操作,而是存放好;遇到运算符时,取出前两个数字进行运算,运算的结果保存;以此类推直至结束。
现在我们根据1 2 + 3 4 + *表达式来模拟计算过程
- 当前数字1,存放。[1]
- 当前数字2,存放。[1,2]
- 当前操作符+,取出前两个数字,即1和2进行加法操作得到3后,存放。[3]
- 当前数字3,存放。[3,3]
- 当前数字4,存放。[3,3,4]
- 当前操作符+,取出前两个数字,即3和4进行加法操作得到7后,存放。[3,7]
- 当前操作符*,取出前两个数字,即3和7进行乘法操作得到21后,存放。[21]
- 扫描到末尾,最后存的数字就是该表达式的结果,即21
可以根据上述模拟过程,每次遇到数字就存放,遇到操作符取出前两个数字,这些数字后进来却先取出,所以能用栈来实现
力扣原题: 150. 逆波兰表达式求值
public int evalRPN(String[] tokens) {
Stack<Integer> stack = new Stack<>();
int index = 0;
while (index< tokens.length){
if(tokens[index].length()==1&&!Character.isDigit(tokens[index].charAt(0))){//当前字段是操作符
int b = stack.pop();
int a = stack.pop();
switch (tokens[index]){
case "+":
stack.push(a+b);
break;
case "-":
stack.push(a-b);
break;
case "*":
stack.push(a*b);
break;
case "/":
stack.push(a/b);
break;
}
}else {//当前字段是数字
stack.push(Integer.parseInt(tokens[index]));
}
index++;
}
return stack.pop();
}
顺序队列
类结构
public class SequenceQueue {
public static final int MAXSIZE = 10;
private int[] queue = new int[MAXSIZE];
private int front = 0;//队首标识
private int rear = 0;//队尾标识
}
操作集
- boolean isEmpty()
- boolean isFull();(⭐)
- void display()
- void append(int data);向队尾添加data元素。(⭐)
- void remove();从队首出队(⭐)
append
public void append(int data){
if(isFull()){
System.out.println("the queue is full,can`t append the data");
return;
}
queue[rear++] = data;
}
remove
public void remove(){
if(isEmpty()){
System.out.println("the queue is empty,can`t remove the data");
return;
}
front++;
}
以上的方法很容易地实现了队列的功能,但细心的朋友会发现这种方法有很大的弊端,因为判断队列是否为满是根据队尾标识来判断,例如下图中,当rear下标为4时,队列是满的,将不能插入新结点,而这时front下标为2,而其实0,1位置因为出队的缘故是没有被使用的,也就是说虽然是队满状态,实际上数组的前部仍有许多空的位置。
为了利用有效空间,可以将顺序存储想象为一个环状的循环队列,把数组中最前和最后两个元素视作相邻的。
在普通队列中,我们知道front = rear时表示队是空的,而到了循环队列中,front = rear也多了队满的可能,也就是说当队首标识和队尾标识相等时,不能区别队列是满还是空的。
解决该问题一种方法是设置一个标识,标识是因为rear加一后等于front(队满),还是因为front加一后等于rear(队空)。另一种方法是牺牲一个数组元素的空间,若数组大小为MAXSIZE,则最多存储MAXSIZE-1的元素。这时队满的条件为
(rear+1)%MAXSIZE = front
队空的条件依然为
rear = front
相关功能的代码也需要修改
isFull
public boolean isFull(){
if((rear+1)%MAXSIZE == front){
return true;
}
return false;
}
append
public void append(int data){
if(isFull()){
System.out.println("the queue is full,can`t append the data");
return;
}
queue[rear] = data;
rear = (rear+1)%MAXSIZE;
}
remove
public void remove(){
if(isEmpty()){
System.out.println("the queue is empty,can`t remove the data");
return;
}
front = (front+1)%MAXSIZE;
}
链式存储
链式存储结构,又叫链接存储结构。在计算机中用一组任意的存储单元存储线性表的数据元素(这组存储单元可以是连续的,也可以是不连续的),在数据结构中,如果一个结点有多个后继和前驱结点,那么用顺序存储实现就是一件麻烦的事情,即便是线性结构,如果没有足够大的连续存储区域可以适用,那么也是无法实现的,链式存储就可以很好解决这些问题。链式存储主要特点如下:
- 比顺序存储结构的存储密度小(链式存储结构中每个结点都由数据域与指针域两部分组成,相比顺序存储结构增加了存储空间)。
- 逻辑上相邻的节点物理上不必相邻。
- 插入、删除灵活 (不必移动节点,只要改变节点中的指针)。
- 查找节点时链式存储要比顺序存储慢。
- 每个结点是由数据域和指针域组成。
- 由于簇是随机分配的,这也使数据删除后覆盖几率降低,恢复可能提高。
单链表
链表可以多花费一个结点的空间作为头结点,方便后续比如插入删除操作的判断,在没有头结点的单链表中,当插入位置为首结点时,需要另外判断,插入后要视新结点为首结点,还要将新的首结点返回。大家可以下去自行比较普通链表和带头链表的区别,所以个人比较偏向带头链表。
类结构
public class LinkedList<T> {
private Node head = new Node();//头结点,头结点的下标视为0
private int listLength = 0;//链表长度(不包含头节点)
private Boolean isCycle = false;//是否为循环链表,顾名思义,如果为循环链表,尾结点需要指向首结点(不是头结点,头结点下一个才是首结点)
private static class Node<T>{
private T data;
private Node next = null;//指向下一结点的指针域
public Node(T data) {
this.data = data;
}
public Node(){
}
}
public LinkedList(Boolean isCycle){
this.isCycle = isCycle;
}
public LinkedList(){
}
操作集
- int getListLength();链表长度的get方法
- Boolean isCycle();链表是否循环的get方法
- void setCycle(Boolean cycle);设置链表是否循环的set方法
- boolean isempty()
- void display()
- void tailAppend(T data);尾插法添加元素,即向链表末尾添加元素。(⭐)
- void headAppend(T data);头插法添加元素,即向链表头部添加元素。(⭐)
- Node find(int position);找到position位置的结点
- void insert(int position, T data);向position位置添加data元素。(⭐)
- void delete(int position);删除position位置的元素。(⭐)
- LinkedList reverse();将链表的元素进行反转,返回反转后的新链表。(⭐)
- LinkedList josephuRing(int start,int number);约瑟夫环问题。(⭐)
tailAppend
尾插法添加新结点
public void tailAppend(T data){
Node point = head;
for (int i = 0; i<listLength; i++){//找到尾结点
point = point.next;
}
Node newNode = new Node(data);
point.next = newNode;//将尾结点指向新结点
if(isCycle==true){//如果是循环链表,尾结点需要指向首结点
newNode.next = head.next;
}
listLength++;
}
可以在类中单独创建一个变量来存储尾结点,这样就不用每次循环找尾结点,提升效率。
headAppend
头插法添加新结点
public void headAppend(T data){
Node point = head;
Node newNode = new Node(data);
newNode.next = head.next;
head.next = newNode;
listLength++;
}
insert
对于链表的插入操作,需要按照这样的顺序进行操作。
- 新结点指向插入位置的原结点
- 原结点的前驱结点指向新结点
例如A->B,现在在之间插入C,需要C先指向B,A再指向C,即A最后才断开与B的联系,如果顺序反的话,当A指向C后,A已经与B没关系了,C就无法链接到B
public void insert(int position, T data){
if(position<=0||position>listLength+1){//0为头结点,不可以插入
System.out.println("the position is illegal");
return;
}
if(position == listLength+1){//如果插入位置为链表尾部,直接尾插法即可,另外视头结点下标为0,所以链表尾部不是listLength,而是listLength+1
tailAppend(data);
return;
}
Node pre = find(position-1);//利用find()方法找到插入位置的原结点的前驱结点
Node pos = find(position);//找到原结点
Node newNode = new Node(data);
newNode.next = pos;//新结点指向原结点
pre.next = newNode;//原结点的前驱结点指向新结点
listLength++;
if (isCycle==true){//如果是循环链表另作处理。
Node tailNode = find(listLength);
tailNode.next = head.next;
}
}
delete
对于删除,只需要让删除位置的原结点的前驱结点指向原结点指向的后驱结点,再将原结点删除即可。
public void delete(int position){
if(position<=0||position>listLength){
System.out.println("the position is illegal");
return;
}
Node pre = find(position-1);
Node pos = find(position);
pre.next = pos.next;//删除操作对循环链表同样适用
pos = null;
listLength--;
}
reverse
利用前面实现的头插法,可以方便的实现链表结点的反转。
public LinkedList reverse(){
LinkedList<T> newList = new LinkedList<>();
Node point = head.next;
for (int i = 0; i<listLength; i++){
newList.headAppend((T)point.data);
point = point.next;
}
return newList;
}
不过上述方法只不过是构建新的链表,让结点的值翻转。如果想让链表本身反转,就不能这么直接了。
我们可以参考力扣上的经典题目:206. 反转链表,在O(n)的时间内直接通过修改各个结点的指针指向来实现反转
实现思路很简单,我们让后一个的next指向前一个,最后返回最后一个结点即可。
重点在于需要保存后驱结点比如当前结点为2,前驱结点为1,为了让2的next指向1,需要事先将3保存,以便继续进行遍历。
/**
* Definition for singly-linked list.
* public class ListNode {
* int val;
* ListNode next;
* ListNode() {}
* ListNode(int val) { this.val = val; }
* ListNode(int val, ListNode next) { this.val = val; this.next = next; }
* }
*/
class Solution {
public ListNode reverseList(ListNode head) {
if(head==null||head.next==null) return head;
ListNode pre = null,cur = head,tmp;
while (cur!=null){
tmp = cur.next;
cur.next = pre;
pre = cur;
cur = tmp;
}
return pre;
}
}
约瑟夫环问题
著名犹太历史学家Josephus有过以下的故事:在罗马人占领乔塔帕特后,39 个犹太人与Josephus及他的朋友躲到一个洞中,39个犹太人决定宁愿死也不要被敌人抓到,于是决定了一个自杀方式,41个人排成一个圆圈,由第1个人开始报数,每报数到第3人该人就必须自杀,然后再由下一个重新报数,直到所有人都自杀身亡为止。然而Josephus 和他的朋友并不想遵从。首先从一个人开始,越过k-2个人(因为第一个人已经被越过),并杀掉第k个人。接着,再越过k-1个人,并杀掉第k个人。这个过程沿着圆圈一直进行,直到最终只剩下一个人留下,这个人就可以继续活着。问题是,给定了和,一开始要站在什么地方才能避免被处决。Josephus要他的朋友先假装遵从,他将朋友与自己安排在第16个与第31个位置,于是逃过了这场死亡游戏。
约瑟夫环问题也称为”丢手绢问题“,是计算机科学和数学一个经典的问题,很自然的可以想到用循环链表解决,以下图为例实现代码。
public LinkedList josephuRing(int start,int number ){//约定编号为start的人开始报数,数到固定值number的那个人出列,
//从这个出列的人下一个重新报数,往复直到所有人出列,输出出队编号序列。
if(start<0||start>listLength||number<=0){
System.out.println("start position or number is illegal");
return null;
}
setCycle(true);//确保该链表是循环链表
LinkedList outList = new LinkedList();//出队链表
Node startNode = find(start);//找到起始位置的结点
Node point = startNode;
if(number==1){//如果报数间隔为1 跟顺序出队没区别
for (int i = 0; i<listLength; i++){
outList.tailAppend(point.data);
point = point.next;
}
return outList;
}
while (listLength>0){//约瑟夫环中还存在元素
//找到出队节点的前一节点
for (int i = 1; i<number-1; i++){
point = point.next;
}
Node tmp = point.next;//暂时存储出队节点
outList.tailAppend(tmp.data);//将出队节点添加到出队链表
point.next = tmp.next;//删除出队节点
tmp = null;
listLength--;
point = point.next;//指向出队节点后一个节点
}
return outList;
}
双向链表
单链表只有指向其后继结点的指针域,这就限制了单链表的结点只能操作其后继结点,而无法直接操作其前驱结点。而双向链表中每个结点除了指向后继结点的指针域,还多出了指向前驱结点的指针域,这在处理某些问题上比单链表简单许多。
类结构
public class DoubleLinkedList {
private Node head = new Node();
private int listLength = 0;
static class Node{
private int data;
private Node next = null;
private Node pre = null;//指向前驱结点的指针域
public Node(int data){
this.data = data;
}
public Node(){}
}
}
tailAppend
public void tailAppend(int data){
Node point = head;
for (int i = 0; i<listLength; i++){
point = point.next;
}
Node newNode = new Node(data);
point.next = newNode;//尾结点的后继结点指向新结点
newNode.pre = point;//新结点的前驱结点指向尾结点
listLength++;
}
双向链表插入操作时,后继结点的链接操作和单链表一致,前驱结点的链接操作也同理,先让新结点指向原结点的前驱结点,再将原结点的前驱结点指向新结点即可。
insert
public void insert(int position, int data){
if(position<=0||position>listLength+1){
System.out.println("the position is illegal");
return;
}
if(position == listLength+1){
tailAppend(data);
return;
}
Node pre = find(position-1);
Node pos = find(position);
Node newNode = new Node(data);
newNode.next = pos;//后继结点的链接
pre.next = newNode;
newNode.pre = pre;//前驱结点的链接
pos.pre = newNode;
listLength++;
}
现在来实现一个特殊的打印功能,即顺序打印一遍再反着打印一遍,来验证双向链表
display
public void display(){
Node point = head.next;
for (int i = 0; i<listLength; i++){
System.out.print(point.data + " ");
point = point.next;
}
point = find(listLength);//回到结尾
for (int i = 0; i<listLength; i++){
System.out.print(point.data + " ");
point = point.pre;
}
System.out.println();
}
LRU缓存
LRU(Least Recently Used,最近最少使用),是一种常用的页面置换算法,选择最近最久未使用的页面予以淘汰。该算法赋予每个页面一个访问字段,用来记录一个页面自上次被访问以来所经历的时间 t,当须淘汰一个页面时,选择现有页面中其 t 值最大的,即最近最少使用的页面予以淘汰。
LRU缓存就可以由双向链表+哈希表来实现,该题是有一定难度的,有兴趣的朋友可以看原题146. LRU 缓存
视频题解:146. LRU 缓存机制 LRU Cache【LeetCode 力扣官方题解】
链式栈
类结构
public class LinkedStack {
private Node top = null;
private int stackSize = 0;
static class Node{
private int data;
private Node next = null;
public Node (int data){ this.data = data;}
}
操作集
- void push(int data);(⭐)
- int pop();(⭐)
- void display()
push
对于入栈操作,只需将新结点指向栈顶的结点,随后将新指针设为栈顶即可。
public void push(int data){
Node newNode = new Node(data);
newNode.next = top;
top = newNode;
stackSize++;
}
pop
出栈操作只需将栈顶指向其下一个结点即可,如果需要接收出栈的结点,可以先用一个临时变量来存储。
public int pop(){
Node tmp = top;
top = top.next;
stackSize--;
return tmp.data;
}
链式队列
由于链式存储的特性,完全不用考虑顺序队列空间没有利用的问题
类结构
public class LinkedQueue {
private Node front = null;
private Node rear = null;
private int queueSize = 0;
static class Node{
private int data;
private Node next = null;
public Node (int data){
this.data = data;
}
}
操作集
- void display()
- void append(int data);(⭐)
- void remove();(⭐)
append
public void append(int data){
Node newNode = new Node(data);
if(queueSize==0)//如果队列是空的,队首队尾标识都指向新结点
front = rear = newNode;
rear.next = newNode;
rear = newNode;
queueSize++;
}
remove
public void remove(){
if(queueSize == 0){
System.out.println("the queue is empty,can`t remove");
return;
}
front = front.next;
}
双端队列
双端队列Deque(double ended queue),是指允许两端都可以进行入队和出队操作的特殊队列
假设左边是Deque的首端,右侧为尾端
从首端出队为pollFirst,入队为offerFirst
从尾端出队为pollLast,入队为offerLast
Deque的设计看起来很奇怪,但只要我们限定操作的规则,双端队列就可以退化成栈和队列使用:当仅允许同侧操作时,就相当于栈;当仅允许异侧操作时,就相当于队列。
如下模拟栈操作,(offerLast/pollLast效果相同)
- offerFirst(1),首端入栈,Deque中数据[1]
- offerFirst(2),[2,1]
- offerFirst(3),[3,2,1]
- pollFirst(),首端出队,[2,1],3出栈
- pollFirst(),[1],2出栈
- pollFirst(),[],1出栈
在后缀表达式中,我们也可以将Stack用Deque替换。事实上,在Java中栈的操作一般就使用Deque来完成,因为Stack实现的是Vector接口,而Vector接口在早期就存在,但设计得不太好,效率较差。
class Solution {
public int evalRPN(String[] tokens) {
Deque<Integer> stack = new ArrayDeque<>();
int index = 0;
while (index< tokens.length){
if(tokens[index].length()==1&&!Character.isDigit(tokens[index].charAt(0))){
int b = stack.pollLast();
int a = stack.pollLast();
switch (tokens[index]){
case "+":
stack.offerLast(a+b);
break;
case "-":
stack.offerLast(a-b);
break;
case "*":
stack.offerLast(a*b);
break;
case "/":
stack.offerLast(a/b);
break;
}
}else {
stack.offerLast(Integer.parseInt(tokens[index]));
}
index++;
}
return stack.pollLast();
}
}
接下来看异侧操作,来模拟队列
- offerFirst(1),首端入队,Deque中数据[1]
- offerFirst(2),[2,1]
- offerFirst(3),[3,2,1]
- pollLast(),尾端出队,[3,2],1出队
- pollLast(),[3],2出队
- pollLast(),[],3出队