数组
- 要求内存空间连续。(方便预读)
- 有固定的初始化大小。
- 采用内存地址计算方式访问下一条数据。
数组空间连续表示 如果没有连续的内存空间,及时剩余空间大小大于初始化所需内存,也无法进行初始化!!
数组介绍与复杂度分析
数组是一组空间连续的线性表。支持随机访问,在通过下标随时访问的复杂度为O(1),但是由于要求空间连续,因此在新增、删除操作复杂度为O(n)。
但是,如果新增数据不要求保证顺序性,那么可以将新增位置原数据直接移动到末尾,其他数据保持不变、复杂度完全可以降低到O(1)
对于删除来说,空间连续是需要保证的,但是不需要实时保证。可以将删除数据做标记,待空间不足时,执行批量删除,这样复杂度也能有效降低。其思想先标记再清除,不就是JVM垃圾回收的思想么。
数组使用场景
结合数组复杂度的特性,在一些可以直接在末尾新增数据,且随机访问数据的场景可以有效的提升查询效率。相对于链表新增来说,虽然链表的新增复杂度为O(1),但是如果考虑到顺序那么复杂度将变为O(n)。
此外在日常工作中我们已经很少去使用数组。大部分情况有使用ArrayList,那么两者区别是什么呢?
首先ArrayList底层也是数组实现,在新增,删除,以及动态扩容方面做了封装,可以放心使用。
那么既然ArrayList容器这么方便数组还有存在必要性么?
首先虽然ArrayList做了封装,但是在一些场景如果使用不当,会造成性能损耗。比如:
有1W个元素需要新增,初始化集合时未指定集合大小。
public void addElement() {
// 未指定初始化大小,新增时会自动扩容N次,产生额外的消耗。
List collection = new ArrayList();
for (int i = 0; i < 10000; i++) {
collection.add(i);
}
}
存储基础类型数据。比如 int, 而 ArrayList不支持基础类型,仅支持包装类。而自动拆装箱也需要消耗一部分性能。
算法分析
指定位置增加元素
-
将指定位置后元组全部后移。
- 后移可以采用尾部遍历方式。
- 头部遍历方式需要额外的临时变量来记录移动前的值。
-
在指定位置加入元素。
有序数组增加元素
思路
以递增数组为例。
-
遍历所有元素。采用尾部遍历方式。
- 因为尾部遍历能够避免创建临时变量。
-
判断元素是否 大于/小于 增加元素。
-
已递增数组为例。判断 当前位置是否 <= 待加入元素
- 如果小于等于 直接赋值加入元素即可。(表示新增元素最大,加至末尾)
- 如果大于,移动该元素到末尾。继续循环
-
实现
private static void addOrderElement(int[] arr, int size, int ele) {
// 注意点 1
if (arr.length <= size) {
throw new IllegalArgumentException();
}
for (int i = size - 1; i >= 0; i--) {
if (arr[i] <= ele) {
arr[i + 1] = ele;
break;
} else {
arr[i + 1] = arr[i];
}
// 注意点 2
if (i == 0) {
arr[i] = ele;
}
}
}
重点
- 加入数组需要有空位。比如数组长度为5,里边已有4个元素,此时新增一个可以。如果长度为4没有办法新增。
- 考虑首、尾新增情况。以上述步骤为例,如果新增元素最大,直接放入尾结点没有问题。如果新增元素最小,当前位置<= 待加入元素始终不成立。最后没办法执行赋值。
链表
- 不要求内存空间连续。(无法有效预读)
- 采用指针方式访问下一条数据。
链表介绍与复杂度分析
链表分为:单向链表、双向链表、循环链表。
链表的复杂度:新增、删除操作为 0(1),随机访问数据为O(n)
单向链表
单向链表由于只有一个next指针,试想如果要在链表中加入一条数据应该如何操作:
- 从头遍历链表,找到要加入的位置。0(n)
- 获取上一个节点,以及下一个节点。0(n)
- 初始化新的节点。
- 修改指针。0(1)
从上来看,使用链表的新增,删除复杂度也不是简单的O(1)。
此外,每次新增或者删除节点,都需要申请/释放内存,容易造成空间碎片,触发GC。
双向链表
双向链表避免了单向列表获取节点的操作,但是随之而来的也是多了一个prev指针,空间占用增大。
这种便是典型的空间换时间思维
链表使用场景
链表的典型应用,LRU算法。解决约瑟夫问题。
链表在使用上要结合实际场景,特点很明显,随机新增删除O(1)复杂度,但是随之而来的是定位繁琐,新增删除复杂度虽然低,但是在频繁操作时又会频繁操申请/释放空间。
所以一般在业务开发中使用的较少。
算法分析
LRU
实现过程重点
前置条件
使用集合模拟存储数据。
记录头节点、尾结点。方便对链表进行操作。
需要考虑的方法包括:CRUD
新增操作
-
需要判断数据是否已存在,存在则无需考虑空间。
-
不存在需要判断空间是否已满
- 空间如果满了需要淘汰末尾节点。
删除操作
- 删除操作需要判断,删除的节点是头结点,尾结点,还是中间节点。
数组实现分析
数组实现LRU也可以。
package com.fishbone.study.caculate.link;
import org.apache.commons.lang3.StringUtils;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
/**
* @Description LRU 测试
*/
public class LruCustom {
private static final Map<String, RedisDataNode> SOURCE_POOL = new HashMap<>();
private RedisDataNode head;
private RedisDataNode tail;
private static final int SIZE = 3;
public static void main(String[] args) {
LruCustom custom = new LruCustom();
custom.saveRedisData("haha", "haha");
printMessage("add haha", custom);
custom.saveRedisData("hehe", "hehe");
printMessage("add hehe", custom);
custom.saveRedisData("heihei", "heihei");
printMessage("add heihei", custom);
custom.saveRedisData("gege", "gege");
printMessage("add gege", custom);
custom.saveRedisData("xixi", "xixi");
printMessage("add xixi", custom);
custom.saveRedisData("heihei", "heihei");
printMessage("add heihei", custom);
String xixi = custom.getRedisData("xixi");
printMessage("select xixi", custom);
custom.removeRedisData("xixi");
printMessage("remove xixi", custom);
}
private static void printMessage(String message, LruCustom custom) {
System.out.println(message);
System.out.println(custom.getHead());
System.out.println(custom.getTail());
System.out.println(getSourcePool());
}
public RedisDataNode getHead() {
return head;
}
public RedisDataNode getTail() {
return tail;
}
public static Map<String, RedisDataNode> getSourcePool() {
return SOURCE_POOL;
}
/**
* 查询Redis数据
* 1. 判断是否包含该数据
* 2. 如果包含,需要返回结果,
* 3. 并将顺序设置为头节点
*
* @param key key
* @return 查询结果
*/
public String getRedisData(String key) {
if (StringUtils.isNotBlank(key) && !SOURCE_POOL.containsKey(key)) {
return "";
}
if (head.getKey().equals(key)) {
return head.getValue();
}
RedisDataNode redisDataNode = SOURCE_POOL.get(key);
this.removeNode(key);
this.addHeader(key, redisDataNode.getValue());
return redisDataNode.getValue();
}
/**
* 新增缓存数据
* 1. 判断缓存数据是否存在 存在需要先删除 再新增
* 2. 如果不存在 判断 缓存空间是否足够, 如果不够需要先淘汰
* 3. 如果空间足够 直接新增即可
*/
public void saveRedisData(String key, String value) {
if (SOURCE_POOL.containsKey(key)) {
this.removeNode(key);
this.addHeader(key, value);
return;
}
if (SOURCE_POOL.size() >= SIZE) {
this.autoRemove();
}
this.addHeader(key, value);
}
/**
* 删除缓存数据
* 1. 判断数据是否存在
* 2. 如果存在 直接删除即可
*/
public void removeRedisData(String key) {
if (SOURCE_POOL.containsKey(key)) {
removeNode(key);
}
}
private void removeNode(String key) {
RedisDataNode redisDataNode = SOURCE_POOL.get(key);
if (redisDataNode == head) {
RedisDataNode next = redisDataNode.next;
next.prev = null;
head = next;
} else if (redisDataNode == tail) {
RedisDataNode prev = redisDataNode.prev;
prev.next = null;
tail = prev;
} else {
RedisDataNode prev = redisDataNode.prev;
RedisDataNode next = redisDataNode.next;
next.prev = prev;
prev.next = next;
}
SOURCE_POOL.remove(key);
}
private void autoRemove() {
RedisDataNode newTail = tail.prev;
SOURCE_POOL.remove(tail.getKey());
newTail.next = null;
tail = newTail;
}
private void addHeader(String key, String value) {
RedisDataNode node = new RedisDataNode(key, value, null, null);
if (Objects.isNull(head)) {
head = node;
tail = node;
} else {
head.prev = node;
node.next = head;
head = node;
}
SOURCE_POOL.put(key, node);
}
public static class RedisDataNode {
private final String key;
private final String value;
public RedisDataNode prev;
public RedisDataNode next;
public RedisDataNode(String key, String value, RedisDataNode prev, RedisDataNode next) {
this.key = key;
this.value = value;
this.prev = prev;
this.next = next;
}
public String getKey() {
return key;
}
public String getValue() {
return value;
}
@Override
public String toString() {
return "RedisDataNode{" +
"key='" + key + ''' +
", value='" + value + ''' +
", next=" + next +
'}';
}
}
}
反转链表
普通反转
- 需要一个null节点,空节点始终作为一个 prev节点。
- 需要一个临时节点,临时节点用作保存next节点。
- 循环条件设置为:反转节点不为空
private static SingleList.Node reverseLink(SingleList.Node head) {
SingleList.Node prev = null;
while (head != null) {
SingleList.Node temp = head.next;
head.next = prev;
prev = head;
head = temp;
}
return prev;
}
禁止空节点
如果要求禁用空节点,直接使用next替换即可。
private static SingleList.Node reverseLink(SingleList.Node head) {
SingleList.Node prev = new SingleList.Node(-1);
while (head != null) {
SingleList.Node temp = head.next;
head.next = prev.next; // next 肯定是 null
prev.next = head;
head = temp;
}
return prev.next;
}
找中间节点
快慢指针
- 快指针每次前进两格,慢指针每次前进一格。
- 对于数量为奇数,慢指针为中间节点。(length + 1)/ 2
- 对于数量为偶数,慢指针为中间节点。(length)/ 2
private static SingleList.Node linkMethod(SingleList.Node head) {
SingleList.Node fast = head;
SingleList.Node slow = head;
int i = 1;
while (fast.next != null) {
i++;
slow = slow.next;
if (fast.next.next == null) {
break;
}
fast = fast.next.next;
i++;
}
return slow;
}
回文字符串判断
- 通过快慢指针找到中间节点。
- 反转链表后半部分。
- 从头节点与反转后的部分进行比对
找倒数第N个节点
假设一个链表从1开始,头结点、尾结点都是正数、倒数第一个元素:
1-》2-》3-》4-》-5》-6》 返回倒数第 3个元素 及节点 4-》5-》6
思路:
- 使用快慢指针,先将快指针往前移动 k个元素,也就是移动到 元素 3 -》4 -》5-》6 此时与 慢指针的间隔也为k个元素。
- 快慢指针同步开始前进。直到快指针走到末尾(next = null),此时慢指针也就走到了倒数K元素位置。返回即可。
private static Node searchNode(Node head, int num) {
Node fast = head;
Node slow = head;
// 使用 --num 考虑: 倒数第一个节点 fast 不需要前进即可,倒数第二个节点 fast需要前进1个,因此直接使用 --num
while (--num > 0 && fast.next != null) {
fast = fast.next;
}
if (num > 0) {
return null;
}
while (fast.next != null) {
fast = fast.next;
slow = slow.next;
}
return slow;
}
判断并查找链表中的环
1 -》2-》3-》4-》5-》2
如上链表,存在环,环的开始节点在 2 。
思路:
如何判断是否有环?
可以使用快慢指针,慢指针一次前进一个节点,快指针一次两个节点。如果最终,快慢节点相同,则判断有回文。(快指针不存在next == null)
如果有回文,那么就需要判断环的大小。此时,慢指针继续前进,直到下一次等于快指针,中间通过节点数量便是环的大小。
如果知道了环的大小,其实就相当于退化为找倒数第N个节点。
private static ListNode detectCycleByTwoPoint2(ListNode head) {
ListNode fast = head;
ListNode slow = head;
// 判断环是否存在
while (fast.next != null) {
fast = fast.next.next;
slow = slow.next;
if (fast == null) {
return null;
}
if (fast == slow) {
break;
}
}
// 统计环大小
int count = 1;
while (fast != slow.next) {
count++;
slow = slow.next;
}
// 获取环起始位置
fast = head;
slow = head;
while (--count > 0) {
fast = fast.next;
}
// fast为环的最后一个节点,由于是环,所以fast.next 为起始节点,因此使用next作为判断条件
while (slow != fast.next) {
slow = slow.next;
fast = fast.next;
}
return slow;
}
有序链表合并
比如:1->3>5>7 0>4>6>9 合并为 0 > 1>3>4>5>6>7>9
思路:
- 有序链表合并,需要同时遍历两个链表,判断值的大小,取小值的放入新的链表,然后,大值链表不动,小值链表继续下一个节点。重复此方法直到某个链表为null。
- 将剩余一个不为null链表直接顺序添加到新链表中即可。
备注:可以使用带头链表形式 创建新的链表
private static Node mergeLink(Node node1, Node node2) {
Node virtual = new Node(-1);
Node cur = virtual;
while (node1 != null && node2 != null) {
if (node1.val <= node2.val) {
cur.next = node1;
node1 = node1.next;
} else {
cur.next = node2;
node2 = node2.next;
}
cur = cur.next;
}
cur.next = node1 != null ? node1 : node2;
return virtual.next;
}
链表疑问点
node.next != null or node != null
关于这两个判断如何区分使用场景呢?
首先理解
// 对于快慢指针,快指针需要一次前进2个节点的需要 用此判断
// 反转链表等 需要用到链表最后一个节点的 不要用next != null
while (node.next != null) {
// 最后一个节点 是不会进入该区域的
}
栈
为什么要有栈?
数组,链表基本已经满足了我们的需求,为什么还有栈这种数据结构呢?
数组实现栈(顺序栈)
- 构造函数初始化数组空间。
头插法:
-
入队:
- 判断数组长度是否满足,不满足先扩容。
- 使用头插法,每次插入元素,向后移动数组。O(nlogn),部分情况不需要移动。
-
出队:
- 从头删除,是否存在元素。
- 后续数组迁移。可以不迁移,需要修改标记位。
尾插法:
-
入队:
- 判断数组长度是否满足,不满足扩容。
- 使用尾插法,每次插入元素,向前移动数组,O(nlogn),部分情况不需要移动。
-
出队
- 从队尾删除,不需要移动元素,但是需要修改标记位。
反思
为什么一定要局限于从固定位置新增、删除?
陷入了思维误区,栈,先进后出,从栈顶出。 栈顶在固有认知中就是队头或者队尾,是一个固定的。思维需要打开,栈顶只是一个标记,我们可以任意修改某个位置为栈顶。
最优解
- 构造函数初始化数组空间。初始化变量记录栈数据长度(同时作为栈顶标记,数组下标)
- 入栈时 arr[count] = item; count++
- 出栈时 arr[count] = null; count--;
public class StackImplTest {
private int[] array;
private int count;
public StackImplTest() {
this.array = new int[16];
count = 0;
}
public static void main(String[] args) {
StackImplTest stack = new StackImplTest();
stack.push(1);
stack.push(2);
stack.push(3);
System.out.println(stack.pop());
stack.push(4);
System.out.println(stack.pop());
System.out.println(stack.pop());
System.out.println(stack.pop());
System.out.println(stack.pop());
}
public boolean push(int item) {
if (count >= array.length) {
return false;
}
array[count++] = item;
return true;
}
public int pop() {
if (count <= 0) {
return -1;
}
return array[--count];
}
}
链表实现栈(链式栈)
-
构造函数初始化带头链表。
-
入栈:
- 头插法,直接在头部新增数据。
-
出栈
- 直接删除头部节点。
注意事项: 新增节点时需要 为节点next 赋值。
public class LinkStackTest {
private Node head;
public LinkStackTest() {
this.head = new Node(-1);
}
public static void main(String[] args) {
LinkStackTest stack = new LinkStackTest();
stack.push(1);
stack.push(2);
stack.push(3);
System.out.println(stack.pop());
stack.push(4);
System.out.println(stack.pop());
System.out.println(stack.pop());
System.out.println(stack.pop());
System.out.println(stack.pop());
}
public boolean push(int item) {
Node node = new Node(item);
// 这里需要注意 需要赋值 next 节点
node.next = head.next;
head.next = node;
return true;
}
public int pop() {
if (head.next == null) {
return -1;
}
Node temp = head.next;
head.next = temp.next;
return temp.val;
}
}
栈的使用场景
括号匹配。常用的JSON格式,包含{},[]两种括号形式,如何快速的判断是否合法,便是通过是否是 开闭括号来确定的。
[]{[]}[},按顺序挨个入栈,如果是左括号,则直接入栈,如果是右括号,从栈顶取一个左括号,如果匹配,则合法继续往下验证,如果不匹配则不合法。
队列
队列可以分为多种。基于其实现方式来说有:顺序队列、链式队列。
基于其功能又可分为:循环队列、阻塞队列、并发队列
循环队列: 队列可以重复使用。达到队尾后可以返回队头继续新增数据。
阻塞队列: 队列包含阻塞操作,在队列中没有数据时,出队方法阻塞。队列满时,入队方法阻塞。
并发队列: 线程安全队列,在入队,出队时保证线程安全。可以使用CAS + 自旋 方式实现高性能队列。
数组实现队列(顺序队列)
顺序循环队列
-
初始化数组空间,
-
使用变量0记录当前出队下标。
-
使用变量0记录当前入队下标。
-
使用变量0记录当前队列元素数量。
-
入队:
- 如果小于数组长度,队尾直接新增
- 如果大于数组长度,从0开始直接新增。
-
出队
- 从出队下标处直接删除,出队下标+1
- 如果下标>数组长度,从0开始直接删除
public class ArrayCycleQueue {
/**
* 当前元素数量
*/
private int elementCount;
/**
* 数据存储队列
*/
protected int[] data;
/**
* 队头 队头新增
*/
protected int head;
/**
* 队尾 队尾删除
*/
protected int tail;
public ArrayCycleQueue(int count) {
this.data = new int[count];
this.elementCount = 0;
this.head = 0;
this.tail = 0;
}
/**
* 先判断是否已满
* 不满直接新增
* head++ 判断是否可以循环
*/
public boolean add(int value) {
if (isFull()) {
System.out.println("队列满了");
return false;
}
data[head++] = value;
this.elementCount++;
if (head > data.length - 1) {
head = head % (data.length);
}
System.out.println("add: " + value);
print();
return true;
}
public int pop() {
if (isNull()) {
return -1;
}
int value = data[tail++];
this.elementCount--;
if (tail > data.length - 1) {
tail = tail % (data.length);
}
System.out.println("pop: " + value);
print();
return value;
}
private boolean isNull() {
return elementCount == 0;
}
private boolean isFull() {
return elementCount >= data.length;
}
public void print() {
if (isNull()) {
System.out.println("");
return;
}
int count = elementCount;
int i = tail;
StringBuilder log = new StringBuilder();
while (count > 0) {
count--;
log.append(data[i++] + ";");
if (i > data.length - 1) {
i = i % (data.length);
}
}
System.out.println("print:" + log);
}
}