文章目录
数组
数组是什么?
在计算机科学中,数组数据结构(英语:array data structure),简称数组(英语:Array),是由相同类型的元素(element)的集合所组成的数据结构,分配一块连续的内存来存储。利用元素的索引(index)可以计算出该元素对应的储存地址。最简单的数据结构类型是一维数组。
数组的时间复杂度
- preappend:O(1)
- 正常情况下preappend时间复杂度是O(n),但是可以进行特性优化到 O(1),采用的方式是申请大一点的数组,然后在头部预留一些空间这样,在preappend的时候,只要把head指针往前移动一个位置就行了。
- append: O(1)
- lookup: O(1)
- insert: O(n)
- delete: O(n)
数组动态扩容
动态数组是静态数组的封装,使得它看起来有了动态的能力。动态数组底层依然是我们熟知的数组。 链表是一种真正动态的数据结构,这是因为使用链表的时候不需要为链表事先指定存放数据的多少。
普通的数组,在面对添加和删除元素需求的时候,为了维护其它元素的相对顺序,需要将一系列元素进行平行复制(下文有具体介绍)。在增加元素时候,有可能一开始为数组预先设置的空间不够,就需要进行额外的操作。基于这样的想法,我们需要一种数据结构,能方便地调用增加、删除和支持扩容操作,这样的数据结构就是动态数组。Java 里的 ArrayList 和 C++ 里 STL 工具库里的 vector 就是使用动态数组的设计思想实现的。
动态数组 没有很难理解的知识点,一般在面试中不要求面试者能够实现,但是动态数组底层的操作、均摊复杂度分析、避免复杂度震荡等知识点需要大家掌握。
插入元素和动态扩容
在指定位置插入元素,需要把指定位置以后的元素 从后向前 逐个向后赋值,然后把插入元素的值复制到指定位置,并维护 size 的语义。
说明:这里 size 表示当前动态数组里真正存放数据的元素个数,在数值上等于马上要添加到动态数组末尾的元素下标。
当数组元素满了的时候,此时原来开辟的空间就不够用了,就需要新申请一块更大的内存空间,然后再 把原来的数组元素依次赋值到新的内存空间 ,才能继续添加元素。扩容的倍数是 超参数,这里我们选择扩容到原来数组长度的 22 倍,具体扩容多少,很多时候需要测试来决定。一次 扩容操作的时间复杂度为 O(N)(这里 N 是数组的长度)。
删除元素和动态收缩
删除指定位置的元素,需要把指定位置以后的元素 从前向后 逐个向前赋值,然后把插入元素的值复制到指定位置,并维护变量 size 的语义。
当数组的真正存放数据的区域减少到数组长度的一半的时候,空出来的空间就没有必要一直占着内存了。很自然地我们想到将目前数组中的元素拷贝到一个容量只有原来数组长度的一半的新数组里。
均摊复杂度分析
虽然一次扩容操作的时间复杂度为 O(N),但是大家想一想,扩容或者缩容的操作不是什么时候都会有的。每一次扩容或者缩容的操作,可以均摊到扩容或者缩容以后的数组的每一个元素上。相比较于在数组里插入元素和删除元素(每一次的操作都需要挪动与之位置相关的若干元素),扩容或者缩容操作平均到每一个元素上是常数次的,因此时间复杂度为 O(1)。这样的复杂度分析方法,称之为 均摊复杂度分析。
均摊复杂度分析应用在一些特殊的场合,是有实际意义的。马上我们会介绍一种特殊的情况,在这种情况下,不能使用均摊复杂度分析。
避免复杂度震荡
加入上面数组在自动缩容后,又添加一个元素,这时候就会触发自动扩容,而扩容之后又删除一个元素,这时候又会触发自动收容,如果正好在临界点来来回回,复杂度是 O(N),就不能使用上面均摊复杂度的方式来计算了。
为了避免这种最坏的情况出现,我们在缩容时候,可以这样操作:当数组的真正存放数据的区域减少到数组长度的 1/4 时候,就缩容为原来的 1/2 。同样这个 1/4 和 1/2 都是理论上的参数,具体这个值是多少,也不是固定的,需要测试得出。
自己实现一个 ArrayList
稀疏数组
先来看一个实际的例子,加入用一个二维数组来标识一个五子棋的棋盘,1、2 表示黑白子,0 表示当前格子为空,所以当棋下到某一步时候,它的棋盘状态可能是如下的状态(特点:多数元素都是0,只有少数的有效元素):
0 0 0 0 0 0 0 0
0 1 0 0 0 0 0 0
0 0 2 0 0 0 0 0
0 0 0 0 0 0 0 0
假如当前有一个五子棋的游戏需要把每一步的棋盘状态都进行保存,那么就需要 n 个如上的数组。假如这是一个单机游戏,那也不需要考虑什么数据压缩的问题,我们可以把数据保存在本地文件中,就算下了上万盘,恐怕数据也就是几 M 多。
但是假如这是一个网络游戏呢,我们就不只对用户了,我们可能有上万甚至上百万的用户,如果这些无效数据都存储到内存中,甚至持久化到数据库,那会大大浪费存储资源。为了解决这问题,并且不影响数组中原有的元素值,我们采用了一种压缩的方式来表示稀疏数组的内容。
稀疏数组的处理方法:
- 记录数组一共有几行几列,一共有多少个不同的值
- 把不同值的元素的行列和值记录在一个小规模的数组中,从而缩小数据的规模
从上面可以看到原始的二维数组转为稀疏数组后,每个有效元素所需的存储空间是原来的 3 倍,所以如果原始数组的有效元素密度比较高,转为稀疏矩阵反而增加所需存储的大小。
稀疏数组的简单实现
二维数组和稀疏数组的相互转换实现:SparseArray.java
二维数组和稀疏数组的相互转换简单测试:SparseArrayTest.java
队列
队列是什么?
数据结构中的队列,就像我们每天上班、买东西,在食堂打饭排队一样,是一种符合「先进先出」规律的数据结构。
队列的时间复杂度
- 入列:o(1)
- 出列:o(1)
队列Queue的java api
\ | Throws exception | Returns special value |
---|---|---|
插入 | add(e) | offer(e) |
删除 | remove() | poll() |
查看 | element() | peek() |
利用数组来实现简单队列
我们知道,在数组的末尾执行操作,时间复杂度是 O(1) 。在数组的起始位置不论是执行删除还是添加操作,时间复杂度都是 O(N) ,如何突破这个复杂度限制呢?
其实只需要修改数组头部这个定义就好了,我们出队列操作只需要移动头部的下标即可:
但是这样会出现了一个问题,如果头部下标到了数组尾部,那么这个数组就被“耗尽”了,而且在头部的前面存在之前已经被释放的空间,难道就要这样被白白浪费掉么?这样显然是不合理的,因此我们需要扩展我们上面的实现,让我们的数组形成一个环状,这样就可以循环利用原来释放的空间了。
利用数组来实现循环队列
其实要在上面的例子的基础上实现循环队列也不难:
- 通过一个 count 变量来维护当前队列的元素个数
- 队列空:count == 0。
- 队列满:count == array.length;
- 通过 % 运算来计算 front 和 rear 的下标。
- API
- ArrayCircleQueue(k):构造器,设置队列长度为 k 。
- front:从队首获取元素。如果队列为空,返回 -1−1。
- rear:获取队尾元素。如果队列为空,返回 -1−1。
- enQueue(value):向循环队列插入一个元素。如果成功插入则返回真。
- deQueue():从循环队列中删除一个元素。如果成功删除则返回真。
- isEmpty():检查循环队列是否为空。
- isFull():检查循环队列是否已满。
循环队列实现(通过了 leetcode 622 题的测试):ArrayCircleQueue.java
利用数组来实现循环双端队列
我们来直接看一下 leetcode 上的 641 题,设计实现双端队列,你的实现需要支持以下操作:
- MyCircularDeque(k):构造函数,双端队列的大小为k。
insertFront():将一个元素添加到双端队列头部。 如果操作成功返回 true。
insertLast():将一个元素添加到双端队列尾部。如果操作成功返回 true。
deleteFront():从双端队列头部删除一个元素。 如果操作成功返回 true。
deleteLast():从双端队列尾部删除一个元素。如果操作成功返回 true。
getFront():从双端队列头部获得一个元素。如果双端队列为空,返回 -1。
getRear():获得双端队列的最后一个元素。 如果双端队列为空,返回 -1。
isEmpty():检查双端队列是否为空。
isFull():检查双端队列是否满了。
循环双端队列相对于循环队列来说,主要多了两个操作:在队首插入元素、在队尾删除元素。而循环队列只需要队首出列(删除元素),队尾入列(添加元素)。
循环队列有很多实现方式,下面只是其中一种。
- front含义:指向队列头元素的位置
- rear含义:指向队列尾部元素的下一个位置
- 数组构成:数组的大小=队列容量+1,这1的大小是一个约定的预留位不存储数据,可以等同为rear所在的位置。
- 队列容量:capacity
- 数组大小:size
- 队列满:(rear + q) % size == front
- 队列空:rear = front
- 初始值:front = 0;rear = 0;
- 有效数据长度:(rear + size - front) % size
- insertFront:
- (front + 1) % size;
- array[front] = value;
- insert Rear:
- array[rear] = value;
- (rear - 1 + size) % size;
- deleteFront:(front - 1 + size) % size;
- deleteRear:(rear + 1) % size;
循环双端队列实现(通过了 leetcode 641 题测试):ArrayCircleDeque2.java
栈
栈是什么
- 栈是一种 后进先出 的数据结构,是一种人为规定的,只能在一端(栈顶)进行插入和删除操作,并且在栈非空的情况下,只能查看 栈顶 的元素的线性数据结构。
- 最先放入栈中元素在栈底,最后放入的元素在栈顶。
- 最后放入的元素最先删除,最先放入的元素最后删除。
为什么限制了「后进先出」?
有一个很自然的问题,如果不做这样的限制,不是应用范围更广吗?这里涉及一些工程上设计的思想:
- 首先,不是功能越多越好。越多的功能很可能带来更多的性能的消耗,需要更多的性能开销;
- 其次,有安全的问题。在生活中,我们给一个人的权限越来越多,很可能会让这个人无所适从,产生差错。一个比较好的办法就是,需要什么,就给什么,并且一个人只做好份内的事情;
- 第三,有了这些特定的数据结构,使用的时候语义也会更加清晰,便于交流。他人也更容易知道我们大概是在解决一个什么问题,使用「栈」就说明处理数据的顺序是「后进先出」。
栈就是一种在设计上 刚刚好 ,并且功能专一的容器。可别小看栈这种看起来受限制的数据结构,它在编程的世界里有着非常广泛的应用。后进先出在人类的世界里看起来像是失去了公平,但是我们很多时候处理的问题,恰好符合了后进先出的规律。
生活中后进先出的例子:
- 吃薯片;
- 从羽毛球筒里拿羽毛球;
- 教师批改作业本;
- 餐厅服务员洗盘子。
栈的经典应用场景
- 递归调用:和子程序的调用类似,只是除了储存下一个指令的地址外,也将参数、区域变量等数据存入堆栈中。
- 计算机中表达式的计算
- 图形的深度优先(depth一first)搜索法。
抽象数据类型
- push(E e):压入元素
- pop():弹出元素
- peek():查看栈顶元素
- isEmpty():栈是否为空
- size():当前栈的元素个数
Java 中的栈
在 Java 中可以看到 java.util.Stack 类的官方文档推荐我们使用 java.util.ArrayDeque 作为实现。这是由于一些历史的原因,Stack 这个类没有设计好。
对于 ArrayDeque 这个类我们的使用建议如下:由于 ArrayDeque 天生不是栈的实现类,因此基于 ArrayDeque 是数组实现的事实,我们都建议添加和删除元素都在 ArrayDeque 的末尾进行,push 操作使用 addLast 代替,而 pop 操作则使用 removeLast 代替, 对于 Deque 的另外一个实现类 LinkedList 也同样如此。
同时在使用 deque 的时候,最好不要同时使用队列和栈的API,即是不要同时使用 offer/poll 和 push/pop,明确使用 addFirst/removeFirst 和 addLast/removeLast 会更加好,否则容易让数据混乱。
明确下列方法的语义:push(在开头添加) 、pop(在开头添加)、peek(在开头查看)、add(在末尾添加)、poll(在开头删除)、offer(在末尾添加)、remove(在开头删除)。
栈的实现
使用数组实现栈
显然,数组不适合在头部进行删除和添加操作的,但是在数组的尾部进行增加和删除操作是非常容易的,一般的做法是设计一个 rear 指针变量(和动态数组那一节介绍的 size 的意义相同),指向下一个可以添加的元素的位置。把新添加的元素直接赋值在 rear 指针变量所在的位置,然后 rear 指针后移一位。
删除操作,其实不用真正将这个元素从数组中抹掉,只要将 rear 指针向前移动一位,也就是说 rear 指针变量的前面的所有元素才是栈里有效的数据的部分,之前 rear 所在位置的元素等待被后来的元素覆盖。
使用上面自定义动态扩容数组实现的栈:MyArrayStack.java
使用链表实现栈
事实上单链表,就可以作为栈的一个经典实现。作为链表,一个经典的实现技巧是使用带有虚拟头结点的链表。通过虚拟的头结点我们可以很方便地在链表的头部和尾部删除元素。
下面我们对栈的上面两种实现做一个简单的对比:
\ | 数组 | 链表 |
---|---|---|
优点 | 访问和删除末尾元素快 | 动态创建结点和销毁结点,不用考虑扩容和缩容 |
缺点 | 需要占用连续的一块内存空间,在扩容和缩容的时候,有一定性能消耗。 | 频繁创建结点和销毁结点其实也有一定性能消耗 |
使用队列实现栈
一般不会使用这种方式来实现栈,只是练习或者面试可能会遇到。
最小栈
leetcode 155 题:设计一个支持 push ,pop ,top 操作,并能在常数时间内检索到最小元素的栈。
- push(x):将元素 x 推入栈中。
- pop():删除栈顶的元素。
- top() :获取栈顶元素。
- getMin() :检索栈中的最小元素。
由于 getMin 的操作需要是 O(1) 的时间复杂度,不能使用遍历的方式,因此这里使用空间换时间的方式,通过使用一个辅助栈来存储每个元素入栈时候的最小值,好比把当时最小值的快照存储起来了。
最小栈实现:MinStack.java
最小栈测试:MinStackTest.java
单调栈
单调栈不是一个新的数据结构,单调栈就是普通的栈。对单调栈中元素的加入和取出依然要满足后进先 的原则。叫它单调栈是因为:在解决一些特定问题的过程中,栈中的元素在数值上 恰好 保持单调性。
用单调栈解决的问题的特点是:找出当前元素左边(或者右边)第 1 个比当前元素大或者小的那个元素:
- 单调大栈找小的
- 单调小栈找大的
下面以找当前元素左边和右边的第一个最大值的下标为例子,写一段简单的模板:
int n = nums.length;
Deque<Integer> deque = new LinkedList<>();
//第 i 个元素左边第 1 个比它大的元素的下标,如果没有值是 -1
int[] lefts = new int[n];
//第 i 个元素右边第 1 个比它大的元素的下标,如果没有值是 -1
int[] rights = new int[n];
Arrays.fill(lefts,-1);
Arrays.fill(rights,-1);
//遍历数组
for(int i = 0; i < n; i++) {
int num = heights[i];
//如果当前栈不为空 && 当前元素 > 栈顶的下标对应的元素
//把比它小的都干掉,它就是最小(年轻)的了
while(deque.size() > 0 && num > heights[deque.peekLast()]) {
//弹出当前栈顶的下标
int index = deque.removeLast();
//nums[index] 右边第一个比它大的元素的下标就是 i
rights[index] = i;
}
if(deque.size() > 0) {
//如果栈不为空,当前元素 nums[i] 左边第 1 个 比自己大的元素下标,就是栈顶的值
lefts[i] = deque.peekLast();
}
//由于比当前元素小的元素都已经弹出了,所以当前元素就是栈里面最小的,依然保存是单调小栈的特性
deque.addLast(i);
}
单调大栈例题:每日气温I
请根据每日 气温 列表,重新生成一个列表。对应位置的输出为:要想观测到更高的气温,至少需要等待的天数。如果气温在这之后都不会升高,请在该位置用 0 来代替。
例如,给定一个列表 temperatures = [73, 74, 75, 71, 69, 72, 76, 73],你的输出应该是 [1, 1, 4, 2, 1, 1, 0, 0]。
提示:气温 列表长度的范围是 [1, 30000]。每个气温的值的均为华氏度,都是在 [30, 100] 范围内的整数。
来源:力扣(LeetCode)
链接:https://leetcode-cn.com/problems/next-greater-element-i
著作权归领扣网络所有。商业转载请联系官方授权,非商业转载请注明出处。
由于这里是找第一个比当前元素大的值,所以使用单调小栈。
class Solution {
public int[] dailyTemperatures(int[] T) {
Deque<Integer> deque = new LinkedList<>();
int[] ans = new int[T.length];
//遍历当前数组
for(int i = 0; i < T.length; i++){
int t = T[i];
//如果当前栈不为空,同时当前元素 大于 栈顶的下标对应的元素
while(deque.size() > 0 && t > T[deque.peekLast()]){
//弹出栈顶下标
int index = deque.removeLast();
//这里问题稍稍改变了一下,是返回第一个比当前元素大的元素和当前元素的位置差
//index 右边第一个比它大的元素就是 i,位置差是 i - index
ans[index] = i - index;
}
//由于比当前元素小的元素都已经弹出了,所以当前元素就是栈里面最小的,依然保存是单调小栈的特性
deque.addLast(i);
}
return ans;
}
}
单调小栈例题:柱状图中最大的矩形
给定 n 个非负整数,用来表示柱状图中各个柱子的高度。每个柱子彼此相邻,且宽度为 1 。
求在该柱状图中,能够勾勒出来的矩形的最大面积。
图中阴影部分为所能勾勒出的最大矩形面积,其面积为 10 个单位。
来源:力扣(LeetCode)
链接:https://leetcode-cn.com/problems/largest-rectangle-in-histogram/
著作权归领扣网络所有。商业转载请联系官方授权,非商业转载请注明出处。
对于上面每一个柱子来说,其最大的面积就是当前柱子的高度 h * 相邻比自己高的柱子的最大宽度 w,比如上图中倒数第 2 个柱子,其 h 是 2,w 是 4 (最后 1 个柱子 ~ 倒数第 4 个柱子),所以其做大面积是 2 * 4 = 8 。同理第 3 个柱子的最大面积就是 5 * 2 = 10。因此我们要做的就是遍历每一个柱子的最大面积,然后比较出其最大值。
那么问题就变成了找每个柱子的 w (相邻比自己高的柱子的最大宽度)。然后这也演变成分别找左边和右边第一个比自己矮的柱子,因为它们之间的宽度(不包括它们本身)就是 w。比如上图中 5 的左边第一个比它矮的是 1,右边第一个比它矮的是 2 ,那么 w 就是它们中间的区域宽度。
因此找小值适合用单调大栈,这也可以直接套用上面找大值的模板,唯一需要修改的是栈顶值的比较:
class Solution {
public int largestRectangleArea(int[] heights) {
//这里往下都是套模板
int n = heights.length;
Deque<Integer> deque = new LinkedList<>();
int[] lefts = new int[n];
int[] rights = new int[n];
Arrays.fill(lefts,-1);
Arrays.fill(rights,-1);
for(int i = 0; i < n; i++) {
int h = heights[i];
//因为这里是使用单调大栈,所以当前值 < 栈顶下标对应元素时候,弹出栈顶元素
//把比它大都干掉,它就是最大了
while(deque.size() > 0 && h < heights[deque.peekLast()]){
int index = deque.removeLast();
rights[index] = i;
}
if(deque.size() > 0) {
lefts[i] = deque.peekLast();
}
deque.addLast(i);
}
//这里往上都是模板
int ans = 0;
for(int i = 0; i < n; i++){
int left = lefts[i] == - 1 ? 0 : lefts[i] + 1;
int right = rights[i] == -1 ? n - 1 : rights[i] - 1;
//计算必须包含第 i 个柱子的最大面积
int area = (right - left + 1) * heights[i];
//比较最大值
ans = Math.max(ans,area);
}
return ans;
}
}