算法学习-数组、链表、堆、栈队列

117 阅读9分钟

数组

  1. 要求内存空间连续。(方便预读)
  2. 有固定的初始化大小。
  3. 采用内存地址计算方式访问下一条数据。

数组空间连续表示 如果没有连续的内存空间,及时剩余空间大小大于初始化所需内存,也无法进行初始化!!

数组介绍与复杂度分析

数组是一组空间连续的线性表。支持随机访问,在通过下标随时访问的复杂度为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不支持基础类型,仅支持包装类。而自动拆装箱也需要消耗一部分性能。

算法分析

指定位置增加元素

  1. 将指定位置后元组全部后移。

    1. 后移可以采用尾部遍历方式。
    2. 头部遍历方式需要额外的临时变量来记录移动前的值。
  2. 在指定位置加入元素。

有序数组增加元素

思路

以递增数组为例。

  1. 遍历所有元素。采用尾部遍历方式。

    1. 因为尾部遍历能够避免创建临时变量。
  2. 判断元素是否 大于/小于 增加元素。

    1. 已递增数组为例。判断 当前位置是否 <= 待加入元素

      1. 如果小于等于 直接赋值加入元素即可。(表示新增元素最大,加至末尾)
      2. 如果大于,移动该元素到末尾。继续循环

实现

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;
        }
    }
}

重点

  1. 加入数组需要有空位。比如数组长度为5,里边已有4个元素,此时新增一个可以。如果长度为4没有办法新增。
  2. 考虑首、尾新增情况。以上述步骤为例,如果新增元素最大,直接放入尾结点没有问题。如果新增元素最小,当前位置<= 待加入元素始终不成立。最后没办法执行赋值。

链表

  1. 不要求内存空间连续。(无法有效预读)
  2. 采用指针方式访问下一条数据。

链表介绍与复杂度分析

链表分为:单向链表、双向链表、循环链表。

链表的复杂度:新增、删除操作为 0(1),随机访问数据为O(n)

单向链表

单向链表由于只有一个next指针,试想如果要在链表中加入一条数据应该如何操作:

  1. 从头遍历链表,找到要加入的位置。0(n)
  2. 获取上一个节点,以及下一个节点。0(n)
  3. 初始化新的节点。
  4. 修改指针。0(1)

从上来看,使用链表的新增,删除复杂度也不是简单的O(1)。

此外,每次新增或者删除节点,都需要申请/释放内存,容易造成空间碎片,触发GC。

双向链表

双向链表避免了单向列表获取节点的操作,但是随之而来的也是多了一个prev指针,空间占用增大。

这种便是典型的空间换时间思维

链表使用场景

链表的典型应用,LRU算法。解决约瑟夫问题。

链表在使用上要结合实际场景,特点很明显,随机新增删除O(1)复杂度,但是随之而来的是定位繁琐,新增删除复杂度虽然低,但是在频繁操作时又会频繁操申请/释放空间。

所以一般在业务开发中使用的较少。

算法分析

LRU

实现过程重点

前置条件

使用集合模拟存储数据。

记录头节点、尾结点。方便对链表进行操作。

需要考虑的方法包括:CRUD

新增操作

  1. 需要判断数据是否已存在,存在则无需考虑空间。

  2. 不存在需要判断空间是否已满

    1. 空间如果满了需要淘汰末尾节点。

删除操作

  1. 删除操作需要判断,删除的节点是头结点,尾结点,还是中间节点。

数组实现分析

数组实现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 +
                    '}';
        }
    }
}

反转链表

普通反转

  1. 需要一个null节点,空节点始终作为一个 prev节点。
  2. 需要一个临时节点,临时节点用作保存next节点。
  3. 循环条件设置为:反转节点不为空
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;
}

找中间节点

快慢指针

  1. 快指针每次前进两格,慢指针每次前进一格。
  2. 对于数量为奇数,慢指针为中间节点。(length + 1)/ 2
  3. 对于数量为偶数,慢指针为中间节点。(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;
}

回文字符串判断

  1. 通过快慢指针找到中间节点。
  2. 反转链表后半部分。
  3. 从头节点与反转后的部分进行比对

找倒数第N个节点

假设一个链表从1开始,头结点、尾结点都是正数、倒数第一个元素:

1-》2-》3-》4-》-5》-6》 返回倒数第 3个元素 及节点 4-》5-》6

思路:

  1. 使用快慢指针,先将快指针往前移动 k个元素,也就是移动到 元素 3 -》4 -》5-》6 此时与 慢指针的间隔也为k个元素。
  2. 快慢指针同步开始前进。直到快指针走到末尾(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

思路:

  1. 有序链表合并,需要同时遍历两个链表,判断值的大小,取小值的放入新的链表,然后,大值链表不动,小值链表继续下一个节点。重复此方法直到某个链表为null。
  2. 将剩余一个不为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) {
    // 最后一个节点 是不会进入该区域的
}

为什么要有栈?

数组,链表基本已经满足了我们的需求,为什么还有栈这种数据结构呢?

数组实现栈(顺序栈)

  1. 构造函数初始化数组空间。

头插法:

  1. 入队:

    1. 判断数组长度是否满足,不满足先扩容。
    2. 使用头插法,每次插入元素,向后移动数组。O(nlogn),部分情况不需要移动。
  2. 出队:

    1. 从头删除,是否存在元素。
    2. 后续数组迁移。可以不迁移,需要修改标记位。

尾插法:

  1. 入队:

    1. 判断数组长度是否满足,不满足扩容。
    2. 使用尾插法,每次插入元素,向前移动数组,O(nlogn),部分情况不需要移动。
  2. 出队

    1. 从队尾删除,不需要移动元素,但是需要修改标记位。

反思

为什么一定要局限于从固定位置新增、删除?

陷入了思维误区,栈,先进后出,从栈顶出。 栈顶在固有认知中就是队头或者队尾,是一个固定的。思维需要打开,栈顶只是一个标记,我们可以任意修改某个位置为栈顶。

最优解

  1. 构造函数初始化数组空间。初始化变量记录栈数据长度(同时作为栈顶标记,数组下标)
  2. 入栈时 arr[count] = item; count++
  3. 出栈时 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];
    }
​
}

链表实现栈(链式栈)

  1. 构造函数初始化带头链表。

  2. 入栈:

    1. 头插法,直接在头部新增数据。
  3. 出栈

    1. 直接删除头部节点。

注意事项: 新增节点时需要 为节点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 + 自旋 方式实现高性能队列。

数组实现队列(顺序队列)

顺序循环队列

  1. 初始化数组空间,

  2. 使用变量0记录当前出队下标。

  3. 使用变量0记录当前入队下标。

  4. 使用变量0记录当前队列元素数量。

  5. 入队:

    1. 如果小于数组长度,队尾直接新增
    2. 如果大于数组长度,从0开始直接新增。
  6. 出队

    1. 从出队下标处直接删除,出队下标+1
    2. 如果下标>数组长度,从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);
    }
}