数组
1:什么是数组?
2:Java中数组的声明及数组的遍历
3:数组天生的优势——索引
4:动态数组
5:封装自己的数组类——增加元素
6:封装自己的数组类——删除元素
7:封装自己的数组类——修改元素,查询元素
8:简单的时间复杂度分析
9:均摊复杂度与复杂度震荡
1:什么是数组?
数组是我们在学习任何一种编程语言最早接触到的数据结构。它是一种相同数据类型的元素存储的集合;数组中各个元素的存储是有先后顺序的,并且它们在内存中也会按照这样的顺序连续存放在一起。
2:Java中数组的声明及数组的遍历
Java中数组的声明
Java语言当中,数组常规的声明方式有三种。
// 第一种
int [] students = new int [50];
// 第二种
int [] scores = new int [3]{99,88,79};
// 第三种
String [] hobby = {"拳击","健身","跑步"}
无论是哪一种声明方式,都可以看出数组的声明直接或间接地指定了数组的大小,只有在声明数组时,告诉计算机数组的大小,计算机才可以在指定的内存中为你声明的数组开辟一串连续的空间。我们可以想象一连串小格子一个挨着一个紧密地拼凑在一起,每一个小格子都装着一个数据,而装着数据的小格子又对应计算机内存上的一个地址,每个小格子所对应的地址是连续的......
Java中数组的遍历
除了while循环,for循环等基本的遍历方式,数组还支持一种特殊的遍历:foreach.举一个简单的例子:
// 声明数组:
int [] scores = new int[50];
// 普通for循环
for(int i=0;i<scores.length;i++){
System.out.println(score);
}
// foreach遍历
for(int score:scores){
System.out.println(score);
}
因为数组在内存中连续排布,所以数组本身就具有可遍历的能力。
3:数组天生的优势——索引
数组最大的优势就是通过索引可以快速查询一个元素。因为数组在内存中开辟了一段空间,这一段连续的空间就是用来存储数组元素的,所以当我们想获取某一个数组索引的元素时,计算机只要通过这个索引就可以在开辟的内存空间中,找到存放这个元素的地址,继而通过内存地址就可以快速查询到这个元素。我们将索引查询的时间复杂度定义为O(1)。在后文有关于时间复杂度的介绍。当数组的索引变得有一定的语意时,数组的应用就更加方便了,例如:int [] students = new int [50];
如果索引代表的是班级里学生们的学号,如:students[21]
代表的是学号为21号的学生,那么这种索引就变得非常有意义。但并非所有有语意的数组索引都适用,例如一个公司有10名员工,现在需要将员工信息存储于一个emp[]数组当中,如果将员工的身份证号作为索引去创建一个数组,那么显然是不合理的。虽然索引变得有意义,但是计算机为了存储10名员工的信息就要在内存上开辟身份证号长度的内存去存储,实在是大大浪费了空间。索引最好建立在有语意的前提下,但是一定要合理。
4:动态数组
什么是动态数组?
了解Java的人一定知道,Java Collecion里面,ArrayList的底层实现原理就是动态数组,那么动态数组的含义是什么呢?在上文我们说过,如果想要声明定义一个数组,都需要直接或间接地告诉计算机我们要声明的数组的大小,只有计算机知道数组的大小后,才可以为我们的数组分配具体的内存空间。但是这样一来,数组就变得不是那么灵活了,当数组元素已满,我们就无法继续添加元素了。如果我们开辟了1000个元素空间的数组,但是仅仅存储10个元素,那这种情况也是不合理的,我们希望数组能够通过自己的存储元素的实际数量去调节自己的容量大小,这就需要数组具备动态的能力。Java 提供的数组不具备动态能力,所以,我们需要自己封装一个我们自己的数组,这个数组需要具备动态调节自身容量大小的能力,即:动态数组。
动态数组的原理
现有一个数组:int [] data = new int[5];
该数组已经无法继续添加元素了,所以我们再初始化一个新的数组,其容量为10,即数组arr容量的2倍:
int [] newData = new int [10];
然后将原数组的所有元素全部都赋值给新的数组。
再将原数组的引用 arr指向 新的数组。
这个过程转换为伪码为:
public void resize(int newCapacity){
E [] newData = (E[]) new Object[newCapacity];
for(int i=0;i<size;i++){
newData[i] = data[i];
}
data= newData;
}
动态数组扩容或者缩容的过程封装成了一个方法:resize.在方法中,使用了泛型,用来代表所有类型的数组。
5:封装自己的数组类——增加元素
现在我们要实现添加元素的方法,这个方法可以在指定的合法的索引位置进行元素的添加。例如:在索引index=1
处添加元素88
-
原数组:
-
在索引 index=1处添加元素 88
过程转换为代码:
public void add(int index,E e){
if(index<0 || index>size)
throw new IllegalArgumentException("Index is Illegal");
if(size == data.length)
resize(2*data.length);
for(int i=this.size-1;i>=index;i--){
data[i+1] = data[i];
}
data[index] = e;
size++;
}
6:封装自己的数组类——删除元素
举例:删除索引为index=1
处的元素88
.
-
原数组
-
在索引 index=1处删除元素 88
-
删除后的数组
可以看到,在本例中,删除元素88后,我们使用size这样一个变量去维护实际数组元素的数量,实际元素的数量已经变为4了,但是原本索引为 index=4 处的元素仍然还在,以用户的角度来看,用户是无法访问data[size] 这样的一个元素的,所以最后的这个元素的存在已经没有意义了,理应被GC回收。这样的元素,被称为:"Loitering Objects",它们存在并没有意义,理应被Java的GC回收机制回收,所以我们需要手动对其进行回收工作(非必需),data[size] = null
就可以让Java的GC进行回收。删除元素的代码为:
public E remove(int index){
if(index<0 || index>=this.size)
throw new IllegalArgumentException("Index is Illegal");
E ret = data[index];
for(int i=index+1;i<=this.size-1;i++){
data[i-1] = data[i];
}
size--;
// data[size] 为loitering Object 最好将其赋值为null 让jvm GC自动进行垃圾回收
data[size] = null;
// &&data.length/2!=0的原因:防止出现当 data.length = 1 且当前无元素即size=0时
if(this.size == data.length/4 && data.length/2!=0)
resize(data.length/2);
return ret;
}
对于代码:
if(this.size == data.length/4 && data.length/2!=0)
resize(data.length/2);
在后面的文章中,有详细的解释。
7:封装自己的数组类——修改元素,查询元素
- 修改元素
public void set(int index,E e){
if(index<0 || index>=this.size)
throw new IllegalArgumentException("Index is Illegal");
data[index] = e;
}
- 查询元素
// 查:get
public E get(int index){
if(index<0 || index>=this.size)
throw new IllegalArgumentException("Index is Illegal");
return data[index];
}
// 查:contains
public boolean contains(E e){
for(int i=0;i<size;i++){
if(data[i].equals(e)){
return true;
}
}
return false;
}
// 查:find
public int find(E e){
for(int i=0;i<size;i++){
if(data[i].equals(e)){
return i;
}
}
return -1;
}
8:简单的时间复杂度分析
时间复杂度分析
常见的时间复杂度有:O(1),O(n),O(n logn),O(n^2),等等,其中O( ) 描述的是算法的运行时间和输入数据的关系。拿数组的索引举例,数组的索引就是一个O(1) 级别的算法,因为知道索引获取元素的过程和数据的数量级没有关系,也就是说无论数组开辟了10的空间还是开辟了100万的内存空间,索引任一下标的时间都是一个常量。再例如程序:
public static int sum(int[]nums){
int sum = 0;
for(int num:nums){
sum+=num;
}
return sum;
}
上面的程序就是一个O(n) 级别的算法,程序是计算nums数组所有元素的和,计算出结果需要将nums数组从头至尾遍历一边,而遍历这个过程则与nums元素的数量n呈线性关系,随着元素个数n越来越大,遍历需要的时间就越来越长。当然,这个时间复杂度分析其实也忽略了很多常数及一些细节,包括使用的语言不同,程序消耗的时间也是有差异的,所以O( )时间复杂度分析分析的只是一种趋势。
简单的时间复杂度分析与比对
O(1),O(n),O(n^2) 这三种级别的算法 哪一种更优秀呢?首先,O(1)级别的算法肯定是最优的,但是也有一定的弊端。拿数组的索引来说,数组之所以能够快速索引,就是因为它是一种以空间来换取时间的数据结构。如果数据的数量级是千万级的,那么数组就要在内存中开辟千万级的内存空间来支持索引,这显然是不现实。那么O(n)级别的算法一定要比O(n^2)级别的算法更优吗?其实也是不一定的,如T1 = 2000n+10000
这是一个O(n)级别的算法,T2 = n*n
这是一个O(n^2)级别的算法。当n取值很小如100时,很显然 T2的时间要小于T1,但是随着n的取值越来越大,O(n)算法的优势就会越来越大。所以从整体上来看O(1)算法最优,O(n)算法要优于O(n^2)级别的算法,实际上也确实如此,O(n)算法不仅仅是优于O(n^2),在海量级的数据下,这两种算法的差异是巨大的。
分析动态数组的时间复杂度
- 添加操作
除了add(int index,E e)
方法,为了方便一些功能 我增加了方法addLast(E e)
以及addFirst(E e)
。先不考虑resize操作带来的影响。
-
addLast(E e)
O(1) -
addFirst(E e)
O(n) -
add(int index,E e)
当index=0时,相当于向数组的头添加一个元素,所有的元素都需要向后挪动一个位置,所以是O(n)的时间复杂度,当index取值为size时,则相当于addLast操作,即向数组的末尾添加一个元素,是O(1)的时间复杂度。index的取值在0~size 的概率是相等的,这里面涉及到概率论的问题,平均而言,add(int index,E e)
的时间复杂度为:O(n/2) 。也就是说addLast (E e)
直接就可以将增加的元素添加到数组的末尾,addFirst (E e)
操作,数组挪动了n个元素,add(int index,E e)
操作平均来讲,数组需要挪动n/2个元素,它消耗的时间也同数组的个数n 呈线性关系,所以可以将add(int index,E e)
看作一个O(n)时间复杂度的操作(仅仅代表该方法的时间复杂度同元素个数呈线性关系)。
整体来看 动态数组的添加元素方法:add是一个O(n)级别时间复杂度的算法。
- 删除操作
除了remove(int index)
方法,为了方便一些功能,我也增加了方法removeFirst()
以及removeLast()
方法。同样,也不考虑resize()方法对删除操作带来的影响。
-
removeFirst()
O(n) -
removeLast()
O(1) -
remove(int index)
对于remove(int index)
方法时间复杂度的分析和add(int index,E e)
方法的分析过程类似,index的取值在 0~n的概率是相等的,平均上来看,remove(int index)
方法会使数组移动n/2个元素,也就是说remove(int index)
操作的时间与数组元素的个数n呈线性关系,remove(int index)
也是一个O(n)级别时间复杂度的算法。
整体上来看,删除操作remove为 O(n)的时间复杂度。
- 修改操作
-
set(int index,E e)
修改操作只有一个方法即:set(int index,E e)
。因为其利用了数组快速索引的特性,所以修改操作为O(1)的时间复杂度。
- 查询操作
-
get(int index)
O(1) contains(E e)
public boolean contains(E e){
for(int i=0;i<size;i++){
if(data[i].equals(e))
return true;
}
return false;
}
contains方法为查看数组中是否包含某个元素,因为contains方法需要将数组整体进行一次遍历,所以contains方法为O(n)的时间复杂度。
find(E e)
public int find(E e){
for(int i=0;i<size;i++){
if(data[i].equals(e))
return i;
}
return -1;
}
find方法为查看是否数组中包含某个元素,如果包含则返回这个元素所在的索引,如果没有则返回-1。find方法为O(n)的时间复杂度。
- resize操作
resize操作的本质是将一个数组的所有元素依次赋值给一个空数组,它涉及到数组的遍历,所以resize方法为O(n)的时间复杂度。
9:均摊复杂度与复杂度震荡
以上,我们简单分析了增删该查操作的时间复杂度,但是除了改查两种操作不涉及到resize扩容或缩容操作外,添加元素和删除元素都有resize这种机制在里面。
均摊复杂度
以时间复杂度为O(1)的方法addLast(E e)
来举例,如果初始化数组的原始capacity为10,开始时,数组内没有任何元素。一直使用addLast(E e)
向数组末尾添加元素,在添加10次后,即第十一次再次添加元素则触发了一次扩容操作,扩容后的capacity为20即 原来的capacity的2倍。而在第21次添加元素操作时,又再次触发了扩容的操作。
也就是说:第n+1次addLast操作会触发依次resize方法。如果将O(1)的操作称作1次基本操作的话,从第1次添加元素至第n+1次添加操作共进行了2n+1次基本操作(resize为O(n),相当于n次基本操作)。n+1次addLast操作,计算机做了2n+1次基本操作即O(1) 的操作,也就是说,平均下来,每1次addLast,计算机就要做(2n+1)/(n+1)次基本的O(1)操作。也就是说当n这个数字趋近无穷大时,则每1次addLast操作,计算机会进行2次基本的O(1)操作,也就是说——addLast操作和n没有关系,它仍然是一个O(1)级别的算法。以上分析的思想就是均摊复杂度的分析思想,同理:其他的方法也可以用均摊复杂度来进行分析,得到的结果是一致的,resize虽然会触发O(n)的操作,但是将resize的O(n)操作平均到每一次O(1)操作上,对我们之前分析的时间复杂度并无结果上的变化。
复杂度震荡
还记得这段代码么?
if(this.size == data.length/4 && data.length/2!=0)
resize(data.length/2);
这段代码是remove(int index)
方法中,data.length/2!=0
是为了防止出现:数组无元素,capacity已经缩容到1的这种情况,防止resize缩容到capacity=0,这很显然是错误的。代码中,当数组元素的个数减少到 数组capacity的四分之一时,触发了缩容,且缩容为当前capacity的一半,为什么要这样写呢?这种写法叫做 Lazy 机制,它是为了解决复杂度震荡的方法。如果我们将代码写成:
if(this.size == data.length/2 && data.length/2!=0)
resize(data.length/2);
那么就会出现一个问题:
当前数组的size为5,capacity为5,现在对数组进行这样的操作:
- addLast,触发扩容扩容成capacity=10
- removeLast,触发缩容,又缩容成capacity=5
- addLast,触发扩容扩容成capacity=10
- removeLast,触发缩容,又缩容成capacity=5
... ...
想必我们已经看到问题的所在了,原本为O(1) 时间复杂度的addLast和removeLast方法硬生生地被玩成了O(n)的算法,出现这个问题的原因就是因为我们在remove操作时,resize太过于着急了(too Eager),所以造成了复杂度震荡。其实解决方法已经给出,就是Lazy机制。当数组元素的个数到capacity的一半时,不着急去缩容,而是等到size==capacity/4时,将capacity的容积缩容为capacity/2。这种看似懒惰的机制,却解决了这样的一个问题。
栈
数据结构学习—— 栈(stack)
- 什么是栈
- 无处不在的栈——栈的应用
- 使用数组实现ArrayStack及时间复杂度分析
- Leetcode20题:有效的括号
什么是栈(stack)
栈(stack)是一种运算受限的线性数据结构,运算受限指的是栈这种数据结构仅允许在一端添加元素,删除元素,这一端被称作栈顶,相对应的另一端则称为栈底。如图所示:
当前栈,如果想要添加元素“D”只能从栈顶部添加,从栈中取出元素则还是从栈顶开始取元素,所以栈是一种后进先出的数据结构即:LIFO(Last In First Out)。
无处不在的栈——栈的应用
一:Undo(撤销操作)
当我们在文档编辑器中输入文字,当发现输入错误时,想要撤销到前一步,这个操作就是Undo。撤销的原理实际上就是栈这种数据结构来设计实现的。例如:李雷在某个文档编辑器上输入文字“我爱韩梅梅”,结果,由于李雷满脑子想的都是韩梅梅的音容笑貌,不小心将内容输入成了“我爱含梅梅”。李雷想将内容恢复到“我爱” 这一步,所以他按了三次“Ctrl+z”,然后又依次将“韩”,“梅”,“梅”三个字输入了进去。
Undo(撤销)看似很高级的操作,背后的原理就是栈。
二:C语言printf()函数
来看一个C语言的问题:
#include<stdio.h>
int main(void){
int i=1;
printf("%d%d%d",i,i++,i++);
return 0;
}
这个程序的运行结果是什么?如果只是知道i++与++i的区别是不足以解决这道问题的。先公布答案,这个程序的运行结果为:321,这与printf的底层原理有关,因为printf的底层实现就是栈。还是拿李雷韩梅梅来举例说明。
printf("我爱韩梅梅");
printf函数首先会将字符串内容从右至左 push到栈中。
然后,再将栈里面的元素依次pop出来,这样我们就能看到"我爱韩梅梅"这个字符串被打印出来了。这道题也是一样,首先将最右边的%d即“i++” push到栈中,i==1,1入栈后,执行++操作,i的值变成了2。按照上述思路依次将所有元素推入栈中,栈的情况为:
将所有的元素出栈,出栈的顺序就是我们看到的打印结果即:321。
三:程序调用系统栈
有如下程序:
A();
function A(){
1 ...
2 B();
3 ...
4 end
}
function B(){
1 ...
2 C();
3 ...
4 end
}
function C(){
1 ...
2 ...
3 ...
4 end
}
程序从A方法开始调用,执行到 A方法的第二行,计算机发现需要执行B方法,这时就会将执行到哪一步这样一个信息压入到系统栈中。例如,定义A2为A方法的第二行,计算机此时将A2压入系统栈,表明执行到了A方法的第二行。
计算机在系统栈压入这样一个信息后,开始执行B方法,执行到B方法的第二行,发现需要执行C方法,于是计算机将B2压入系统栈中。
计算机开始执行C方法,C方法中没有调用其他的函数,执行结束后,计算机发现系统栈中有残留的任务,于是pop stack 发现需要回去执行完B方法,且执行到了B方法的第二行。B方法执行完毕后,计算机又去看了看系统栈,发现仍有残留的任务需要执行,于是乎又 pop stack 发现原来A方法还没有执行完毕,且执行到了第二行,所以计算机又将A方法执行完毕。这时系统栈为空,计算机终于松了一口气,知道所有的任务已经执行完毕了~
使用数组实现ArrayStack及时间复杂度分析
本文中ArrayStack的底层实现数组为动态数组:动态数组,DobbyKim's Blog。
public interface Stack<E> {
void push(E e);
E pop();
E peek();
int getSize();
boolean isEmpty();
}
public class ArrayStack<E> implements Stack<E>{
Array<E> array;
public ArrayStack(int capacity){
array = new Array<>(capacity);
}
public ArrayStack(){
array = new Array<>();
}
public void push(E e){...}
public E pop(){...}
public int getSize(){...}
public int getCapacity(){...}
public boolean isEmpty(){...}
public E peek(){...}
public String toString(){...}
}
点击查看源码
ArrayStack的方法push 与 pop 的均摊时间复杂度为O(1),因为这里面涉及到底层实现Array为动态数组,resize()扩容操作为一个O(n)的算法。getSize()方法,peek()方法,isEmpty()方法的时间复杂度均为O(1)。
Leetcode20题:有效的括号
给定一个只包括 '(',')','{','}','[',']' 的字符串,判断字符串是否有效。
有效字符串需满足:
左括号必须用相同类型的右括号闭合。
左括号必须以正确的顺序闭合。
注意空字符串可被认为是有效字符串。
- 示例 1:
输入: "()"
输出: true
- 示例 2:
输入: "()[]{}"
输出: true
- 示例 3:
输入: "(]"
输出: false
- 示例 4:
输入: "([)]"
输出: false
- 示例 5:
输入: "{[]}"
输出: true
问题解决思路:栈。只要是左侧的括号为'(','[','{'就push到栈中,遇到与之匹配的右侧括号则pop,最后栈如果为空则说明匹配成功。Java代码如下:
import java.util.Stack
class Solution {
public boolean isValid(String s) {
Stack<Character>stack = new Stack<>();
for(int i=0;i<s.length();i++){
if(s.charAt(i)=='(' || s.charAt(i)=='[' || s.charAt(i)=='{'){
stack.push(s.charAt(i));
}else{
if(stack.isEmpty())
return false;
char c = stack.pop;
if(s.charAt(i)==')' && c!='(')
return false;
if(s.charAt(i)==']' && c!='[')
return false;
if(s.charAt(i)=='}' && c!='{')
return false;
}
}
return stack.isEmpty();
}
}
队列
数据结构学习之——队列与循环队列
- 什么是队列(Queue)
- 队列基于动态数组的实现及时间复杂度分析
- 优化队列
- 循环队列(LoopQueue)
什么是队列(Queue)
队列(Queue)同栈(stack)一样也是一种运算收限的线性数据结构,参考:数据结构之——栈。栈即:LIFO(Last In First Out),队列则是FIFO(First In First Out),也就是说队列这种数据结构仅允许在一端(队尾)添加元素,在另一端(队首)删除元素,所以说队列是一种先进先出的数据结构。可以将队列想象成银行柜台的排队机制一样,在前面排队的人可以先办理业务,在最后排队的人等到前面所有的人办理完毕后,才可以进行业务的处理,如图:
队列基于动态数组的实现及时间复杂度分析
队列同ArrayStack的实现一样,都需要基于动态数组作为底层实现。
动态数组实现代码,动态数组实现原理
在设计模式上,同ArrayStack一样,设计的是Queue这样一个接口,并创建ArrayQueue这样一个类implements这个接口,Queue接口的方法与Stack栈的方法大体相同,只不过我们将入栈push设计成enqueue(入队),出栈pop设计为dequeue(出队)。接口代码如下:
public interface Queue<E>{
void enqueue(E e);
E dequeue();
E getFront();
int getSize();
boolean is Empty();
}
ArrayQueue代码如下:
public class ArrayQueue<E> implements Queue<E>{
private Array<E> array;
public ArrayQueue(int capacity){
array = new Array<>(capacity);
}
public ArrayQueue(){
array = new Array<>();
}
@Override
public int getSize(){
return array.getSize();
}
@Override
public boolean isEmpty(){
return array.isEmpty();
}
public int getCapacity(){
return array.getCapacity();
}
@Override
public void enqueue(E e){
array.addLast(e);
}
@Override
public E dequeue(){
return array.removeFirst();
}
@Override
public E getFront(){
if(array.isEmpty)
throw new IllegalArgumentException("ArrayQueue is Empty");
return array.get(0)
}
@Override
public String toString(){
StringBuilder sb = new StringBuilder();
sb.append("Queue:\n");
sb.append("front:[");
for(int i=0;i<getSize();i++){
sb.append(array.get(i));
if(i!=getSize()-1){
sb.append(",");
}else{
sb.append("]tail");
}
}
return sb.toString();
}
}
时间复杂度分析如下:
E getFront();
int getSize();
boolean is Empty();
以上方法,时间复杂度为:O(1)。
void enqueue(E e);
因为入队操作相当于在动态数组的尾部添加元素,虽然有resize()这样一个O(n)级别的算法,但是以均摊时间复杂度分析,enqueue操作仍然是一个O(1)级别的算法。
E dequeue();
dequeue()操作相当于动态数组的removeFirst()操作,在数组的头部删除一个元素,array[0] 后面的所有元素都需要向前挪动一个位置,所以dequeue出队是一个O(n)级别的算法。
综上分析,ArrayQueue还是有些不完美的地方,ArrayStack所有的操作均为O(1)级别的算法,但是基于动态数组实现的队列ArrayQueue 在出队操作dequeue上性能还是略显不足,LoopQueue(循环队列)就优化了ArrayQueue出队这样一个问题。
优化ArrayQueue
我们已经知道了,ArrayQueue美中不足的地方就在于dequeue这样一个操作是O(n)级别的算法。出现这个问题的原因实际上是因为每次进行出队操作时,动态数组都需要将array[0]后面所有的元素向前挪动一个单位,但实际上想一想这个过程并不“划算”,因为,队列元素的数量达到万以上的级别时,仅仅删除一个元素,我们就需要将所有的元素进行一次大换血。和银行柜台业务办理的排队不同,银行柜台的一号办理人办理完毕,后面所有的人只需要上前一小步就可以了,但是对于计算机来讲,每一个数据的调整都需要计算机亲历躬行。有什么办法可以避免这种大规模的动辄呢?我们可以使用两个变量去维护队列的队首和队尾。
假设变量为front和tail。front维护队首,变量tail维护队尾,当front==tail时,队列为空,每增加一个元素,tail就像后移动一个单位,所以我们需要多使用一个空间去维护 tail这样一个变量,这样我们在设计模式上就需要开辟用户指定的capacity加1的数组空间。对于用户来讲,capacity==n,但实际上真正的capacity也就是data.length==n+1,因为我们需要多浪费一个空间去维护tail这个变量,但是浪费这样一个空间相比于dequeue的大换血O(n)操作也是值得的。
示例:enqueue元素“D”
示例:dequeue
上述思路优化后的队列MyQueue代码如下:
public class MyQueue<E> implements Queue<E> {
private int front;
private int tail;
private E[]data;
public MyQueue(int capacity){
data = (E[])new Object[capacity+1];
}
public MyQueue(){
this(10);
}
@Override
public int getSize(){
return tail-front;
}
@Override
public boolean isEmpty(){
return front==tail;
}
@Override
public E getFront(){
return data[front];
}
private void resize(int newCapacity){
E[]newData = (E[])new Object[newCapacity+1];
for(int i=0;i<(tail-front);i++){
newData[i] = data[front+i];
}
data = newData;
tail = tail - front;
front = 0;
}
@Override
public void enqueue(E e){
if(tail == data.length-1)
resize((tail-front)*2);
data[tail] = e;
tail++;
}
@Override
public E dequeue(){
E ret = data[front];
// Loitering Object
data[front] = null;
front++;
if(((tail-front)==(data.length-1)/4) && (data.length-1)/2!=0)
resize((data.length-1)/2);
return ret;
}
@Override
public String toString(){
StringBuilder stringBuilder = new StringBuilder();
stringBuilder.append(String.format("MyQueue size=%d,capacity=%d\n",tail-front,data.length-1));
stringBuilder.append("front:[");
for(int i=front;i<tail;i++){
stringBuilder.append(data[i]);
if((i+1)!=tail){
stringBuilder.append(",");
}
}
stringBuilder.append("]tail");
return stringBuilder.toString();
}
}
MyQueue对ArrayQueue进行了优化操作,原本dequeue需要O(n)的时间复杂度,进行优化后,dequeue由O(n)的时间复杂度变为了均摊O(1)的时间复杂度。这里面需要注意的是:MyQueue的enqueue入队操作规定了tail == data.length-1时进行“扩容”操作,这里面扩容二字我使用了双引号,因为有可能这个“扩容”实际上是缩容。
我们规定了enqueue操作中,
if(tail == data.length-1)
resize((tail-front)*2);
在上图的示例中,如果reisze,我们实际上就相当于进行了缩容的操作。我们这样的设计看起来解决了问题,但仍然不灵活,我们希望在入队时的resize只涉及到扩容,出队时的resize只涉及缩容,我们是否能对这样的需求进行优化呢?
循环队列(LoopQueue)
循环队列的思想和ArrayQueue优化后的MyQueue大体相同,只不过循环队列里面加入了更加巧妙的循环机制。
上例中,我们规定tail == data.length-1队列为满进行resize,但是这一次我们换一种思路。当继续向当前队列添加元素时,我们这样做:
变量tail重新回到了起点,这也就是循环队列称之为“循环”的意义所在。那么什么时候表示当前队列已满需要进行resize呢?
当front == (tail+1)%data.length,当这个条件成立时,也就说明了队列为满,需要进行扩容操作了。循环队列实现代码如下:
public class LoopQueue<E> implements Queue<E> {
private E[]data;
private int front;
private int tail;
public LoopQueue(int capacity){
data = (E[])new Object[capacity+1];
}
public LoopQueue(){
this(10);
}
@Override
public int getSize(){
if(tail<front)
return data.length-(front-tail);
else{
return tail-front;
}
}
public int getCapacity(){
return data.length-1;
}
@Override
public boolean isEmpty(){
return getSize()==0;
}
private void resize(int newCapacity){
E[]newData = (E[])new Object[newCapacity+1];
for(int i=0;i<getSize();i++){
newData[i] = data[(i+front)%data.length];
}
data = newData;
tail = getSize();
front = 0;
}
@Override
public void enqueue(E e){
if(front==(tail+1)%data.length)
resize(2*getSize());
data[tail] = e;
tail = (tail+1)%data.length;
}
@Override
public E dequeue(){
E ret = data[front];
// Loitering Object
data[front] = null;
front = (front+1)%data.length;
if(getSize() == getCapacity()/4 && getCapacity()/2!=0){
resize(getCapacity()/2);
}
return ret;
}
@Override
public E getFront(){
if(getSize()==0)
throw new IllegalArgumentException("LoopQueue is empty");
return data[front];
}
@Override
public String toString(){
StringBuilder stringBuilder = new StringBuilder();
stringBuilder.append(String.format("LoopQueue size:%d,capacity:%d\n",getSize(),getCapacity()));
stringBuilder.append("front:[");
for(int i=front;i!=tail;i=(i+1)%data.length){
stringBuilder.append(data[i]);
if((i+1)%data.length!=tail)
stringBuilder.append(",");
}
stringBuilder.append("]tail");
return stringBuilder.toString();
}
}
现在我们对ArrayQueue,LoopQueue进行性能上的测试,
import java.util.Random;
public class Main {
private static double testQueue(Queue<Integer>q,int testCount){
long startTime = System.nanoTime();
Random random = new Random();
for(int i=0;i<testCount;i++)
q.enqueue(random.nextInt(Integer.MAX_VALUE));
for(int i=0;i<testCount;i++)
q.dequeue();
long endTime = System.nanoTime();
return (endTime-startTime)/1000000000.0;
}
public static void main(String[]args){
int testCount = 100000;
ArrayQueue<Integer> arrayQueue = new ArrayQueue<>();
LoopQueue<Integer> loopQueue = new LoopQueue<>();
double loopQueueTime = testQueue(loopQueue,testCount);
double arrayQueueTime = testQueue(arrayQueue,testCount);
System.out.println("LoopQueue:"+loopQueueTime);
System.out.println("ArrayQueue"+arrayQueueTime);
}
}
在jdk1.8的环境下测试结果为:
导致两者之间造成巨大差异的结果就是dequeue操作,ArrayQueue的dequeue操作为O(n)级别的算法,而LoopQueue的dequeue操作 在均摊的时间复杂度上为O(1)。
链表
- 链表与数组
- 使用虚拟头节点
- 链表的增删改查
- 链表的时间复杂度分析
- 使用链表实现栈与队列
链表与数组
链表是典型的线性动态数据结构,也是学习树形数据结构的敲门砖。与数组不同,链表的意义在于动态二字。再回顾一下什么是数组:在内存中开辟一段连续的存储空间的相同数据类型元素存储的集合 。数组并不具备动态的能力,为了让数组具有动态的特性,我们可以实现自己的数组,让其具备自动扩容以及缩容(resize)的能力。动态数组。
而对于栈,与队列这两种具备特殊功能的线性数据结构,可以使用数组作为底层原理来实现。对于栈的特性即LIFO,使用动态数组作为底层实现满足了栈各个功能的时间复杂度为O(1)。而队列的特性为:FIFO,如果使用数组作为底层,在队列的出队操作时,这一项功能的时间复杂度就为O(n)。使用循环队列的思想,则可以将出队操作优化至O(1)。
链表则是一种真正的动态数据结构。因为数组在内存的空间是连续的,所以最大的优势 是支持“随机访问”,而链表最大的优点则是“真正的动态”。链表不会浪费多余的内存空间,不需要处理容量的问题,但是也丧失了数组的随机访问的能力。
上图表示的就是一个链表,图中的圆形表示为链表中的一个节点,每个节点都存储着下一个节点的引用。链表的尾部,也就是链中最后一个节点,是没有指向下一个节点的,所以自然指向了Null。要想实现 “一个节点存储着下一个节点的引用”功能并不难,我们只需要在链表类中,增加一个内部类Node即可:
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();
}
}
// 指向链表头
private Node head;
private int size;
public LinkedList(){
head = null;
size = 0;
}
// 获取链表中元素的个数
public int getSize(){
return size;
}
// 判断链表是否为空
public boolean isEmpty(){
return size==0;
}
}
链表中每一个节点都存储着下一个节点的引用,那么谁来存储链表头部的引用呢?所以,与数组不同,链表需要额外去维护一个变量,这个变量我们称作head,用于存储链表头的引用。
使用虚拟头节点
现在向链表添加元素。
我们需要考虑两种情况,第一种情况为:向链表头部添加元素。
在向链表头部添加元素时,我们需要:
1:newNode.next = head;// 将添加的节点的next指向head
2: head = newNode;// 将head再次指向头部
实现代码为:
public void addFirst(E e){
head = new Node(e,head);
size++;
}
还有一种情况是:在链表任意位置添加元素,这一点和在链表头部添加元素略有不同。(普遍来讲,当你选择了链表这种数据结构时,往往不会涉及向链表的中间添加元素,实现此功能是为了更加深入地学习链表)
我们考虑一下,在链表中间插入元素时,假设插入位置的"索引"称作index。我们首先需要将插入的节点指向index处的节点,本图为向index==2的位置插入元素99。然后再将index位置前的一个节点指向被插入的节点,那么我们如何获取index-1处的节点呢?答案就是遍历。我们需要一个特殊的变量,让它指向index-1处的节点,现在用prev表示这个变量,最初让
prev=head
,每次让prev=prev.next
,遍历index-1次,就可以获得index-1处的,也就是待插入位置的前一个位置的索引处的节点。插入的过程为:
1: newNode.next = prev.next
2: prev.next = newNode
代码为:
public void add(int index,E e){
if(index<0 || index>size)
throw new IllegalArgumentException("Index is Illegal");
if(index==0){
// 如果在链表头部添加元素
addFirst(e);
}else{
Node prev = head;
for(int i=0;i<index-1;i++){
prev = prev.next;
}
prev.next = new Node(e,prev.next);
size++;
}
}
如果使用head这个变量去维护链表头自然是可以的,但是我们看到了,我们的链表在头部添加元素时,和在其他位置添加元素的思路是不一样的。有没有办法能够将链表进行优化,使得链表的头部同链表的其他位置在增删改查的操作一致呢?使用虚拟头节点就可以优化链表,解决这样的一个问题。
如上图所示,我们在原本的head前使用一个dummyHead这样的一个变量,让它指向原本的head,这样对于我们来讲,链表中所有的节点都满足了“有指向它的节点”这样一个特性。
链表的增删改查
有了dummyHead虚拟头节点后,链表的增删改查都会变的非常容易。
向链表中添加元素
public void add(int index,E e){
if(index<0 || index>e)
throw new IllegalArgumentException("Index is Illegal");
Node prev = dummyHead;
for(int i=0;i<index;i++){
prev = prev.next;
}
prev.next = new Node(e,prev.next);
size++;
}
// 在链表头添加新的元素e
public void addFirst(E e){
add(0,e);
}
// 在链表尾添加新的元素e
public void addLast(E e){
add(size,e);
}
向链表中删除元素
public E remove(int index){
if(index<0 || index>=size)
throw new IllegalArgumentException("index is Illegal");
Node prev = dummyHead;
for(int i=0;i<index;i++){
prev = prev.next;
}
E delNode = prev.next;
prev.next = prev.next.next; // prev.next = delNode.next;
delNode.next = null;
return delNode.e;
}
// 从链表中删除第一个元素,并返回
public E removeFirst(){
return remove(0);
}
// 从链表中删除最后一个元素,并返回
public E removeLast(){
return remove(size-1);
}
向链表中查询及修改元素
// 改
public void set(int index,E e){
if(index<0 || index>=size)
throw new IllegalArgumentException("Index is Illegal");
Node prev = dummyHead;
for(int i=0;i<index;i++){
prev = prev.next;
}
prev.next.e = e;
}
// 查
public E get(int index){
if(index<0 || index>=size)
throw new IllegalArgumentException("Index is Illegal");
Node prev = dummyHead;
for(int i=0;i<index;i++){
prev = prev.next;
}
return prev.next.e;
}
// 获得链表的第一个元素
public E getFirst(){
return get(0);
}
// 获得链表的最后一个元素
public E getLast(){
return get(size-1);
}
链表的时间复杂度分析
我们现在来看一下链表的增删改查各个操作的时间复杂度:
- 向链表中添加元素
与动态数组相反,在动态数组的末尾添加元素的时间复杂度为O(1),在头部添加元素则需要将数组整体向后挪动,需要O(n)的时间复杂度。链表的添加元素操作中,在链表头添加元素的时间复杂度为O(1),在链表尾部添加元素,需要将链表遍历一遍,所以时间复杂度则为O(n)。 - 向链表中删除元素
在链表头部删除元素非常简单,时间复杂度为O(1)。删除链表尾部还是需要将链表整体遍历,所以时间复杂度为O(n)。 - 链表中查询元素
链表不具备数组的下标索引这种快速查询的机制,所以对于链表来说,查询元素的时间复杂度为O(n)。因为只有将链表进行遍历,才能知道链表中是否有你想要查询的元素,对于链表来说,查询元素这个功能是不利的,事实上,也确实如此。选择了链表这种数据结构主要的操作都是在增删上,而数组这种数据结构则更加适合查询操作,因为数组的索引特性使得查询操作为O(1)的时间复杂度。 - 链表中修改元素
对于链表的修改元素这一操作来说,在链表头操作的时间复杂度为O(1),在链表尾修改元素的时间复杂度则是O(n)。
使用链表实现栈与队列
栈与队列是两种特殊的线性数据结构,它们都是基于某种线性数据结构作为底层进行实现的。动态数组作为底层可以实现栈与队列,并且我们使得栈这种数据结构的各个操作均为O(1)的时间复杂度,而队列在使用数组作为底层实现时,出队操作的时间复杂度为O(n),但是循环队列则做出了改进,将队列的各个操作优化至O(1)。我们再回顾一下栈与队列的接口方法:
Stack
public interface Stack<E> {
// 入栈
void push(E e);
// 出栈
E pop();
// 查看栈顶元素
E peek();
int getSize();
boolean isEmpty();
}
Queue
public interface Queue<E> {
// 入队
void enqueue(E e);
// 出队
E dequeue();
// 查看队首的元素
E getFront();
int getSize();
boolean isEmpty();
}
如果将栈与队列的底层变为链表,那么如何进行实现呢?
LinkedListStack
对于链表来说,在链表头操作元素均为O(1)的时间复杂度,而栈是一种仅在栈顶进行push与pop的特殊的数据结构。所以我们的思路非常简单,将链表头作为栈顶就可以使得栈的相关操作为O(1)的时间复杂度了,因为代码比较简单,所以直接给出链接,不再叙述:链接。
LinkedListQueue
队列和栈不同,因为FIFO的这种特性,就需要在队列的两头进行操作(从一端添加元素,从另一端删除元素)。对于数组和链表两种数据结构来说,无论是哪一种,在两端进行操作的时间复杂度必是O(1)和O(n)。对于数组来说,我们使用了循环队列这种思想对出队操作进行优化,对于链表也必然有优化的方法,试想一下,在链表头部进行操作的时间复杂度为O(1),如果在链表的尾部也添加一个变量进行维护,那么每次在添加元素时,只需要让尾部指向新添加的元素,并且再次让维护链表尾部的这个变量指向最后一个元素不就可以了吗?假设维护链表尾部的这个变量叫做"tail",在每次向链表中添加元素时,我们只需要tail.next = newNode;tail = newNode
就可以了,这样在链表尾部添加元素就会变为一个时间复杂度为O(1)的操作。
而链表头无论是删除元素还是添加元素都是O(1),我们可以将链表尾部变为队列尾,将链表头当作队列头。
我们只看入队操作和出队操作:
// 链表为底层的队列:入队
@Override
public void enqueue(E e){
Node node = new Node(e);
if(isEmpty()){
head = node;
tail = node;
}else{
tail.next = node;
tail = tail.next;
}
size++;
}
// 链表为底层的队列:出队
@Override
public E dequeue(){
if(isEmpty())
throw new IllegalArgumentException("Queue is Empty");
Node retNode = head;
if(head==tail){
head = null;
tail = null;
}else{
head = head.next;
}
size--;
retNode.next = null;
return retNode.e;
}
====================================================================
本文正在参与“写编程博客瓜分千元现金”活动,关注公众号“饥人谷”回复“编程博客”参与活动