00 DSA-- 入门、实现动态数组、实现链表、栈和队列、环形数组、哈希表

两种代码模式

核心代码模式

核心代码模式:就是给你一个函数框架,你需要实现函数逻辑,这种模式一般称之为。

目前大部分刷题平台和技术面试/笔试场景都是核心代码模式。

比如力扣第一题两数之和,就是给出 twoSum 函数的框架如下,然后要求给出具体实现

java 复制代码
class Solution 
	// 即后台会自动输入数组 nums 和目标值 target,你需要实现算法逻辑,最后计算返回一个数组。
    public int[] twoSum(int[] nums, int target) {
        
    }
}

ACM 模式

简单来说,ACM 模式就是预先不提供函数框架,代码提交到后台后,系统会把每个测试用例(字符串)用标准输入传给你的程序,然后对比你程序的标准输出和预期输出是否相同,以此判断你的代码是否正确。

对于 ACM 模式,一个技巧就是要对代码分层,把对字符串输入的处理逻辑和真正的算法逻辑分开,类似这样:

java 复制代码
public class Main {
    public static void main(String[] args) {
        Scanner scanner = new Scanner(System.in);
        // 主要负责接收处理输入的数据,构建输入的用例
        int[] nums;
        int target;
        int N = scanner.nextInt();
        ...

        // 调用算法逻辑进行求解
        System.out.println(twoSum(nums, target));
    }

    public static int[] twoSum(int[] nums, int target) {
        // 转化为核心代码模式,在这里写算法逻辑
    }
}

常见错误

  • 编译错误(Compile Error)
    • 代码无法通过编译,这种错误一般是语法错误,比如拼写错误、缺少分号等。一般网页上手搓代码容易出现这种错误
  • 运行错误(Runtime Error)
    • 代码可以编译通过,但是在运行时出现了数组越界、空指针异常等错误。这种错误一般是由于边界条件处理不当,你需要留意边界条件、特殊测试用例(也叫做 Corner case,比如输入为空等情况)。
  • 答案错误(Wrong Answer)
    • 代码可以编译通过,也可以运行,但是某些测试用返回的结果和正确答案不一致。这种错误一般是因为你的算法有问题,需要你根据出错的用例反思,甚至可能整个思路都错了,需要你推倒重来。
  • 超时(Time Limit Exceeded,常简称为 TLE)
  • 力扣预定义的测试用例中,越靠后的用例数据规模就越大。如果你的算法的时间复杂度太高,在运行这些大规模的测试用例时就容易超过系统规定的时间限制,导致超时错误。如果你卡在大规模的测试用例,一般就能说明你的算法结果是正确的,因为前面的小规模测试用例都通过了,只不过是时间复杂度太高,需要优化。可能的错误有:
    • 是否有冗余计算,是否有更高效的解法思路来降低时间复杂度。
    • 是否有编码错误,比如边界控制出错,错误地传值传引用导致无意义的数据拷贝等。
  • 笔试中,一般是按照通过的测试用例数量来计分的。所以如果你实在是想不出最优解法通过所有用例,提交一个暴力解,过几个用例捞点分,也是一种聪明的策略。
  • 内存超限(Memory Limit Exceeded,常简称为 MLE)
    • 和超时错误类似,内存超限一般是算法的空间复杂度太高,在运行大数据规模的测试用例时,占用的内存超过了系统规定的内存限制。想要解决这个问题,你需要检查以下几点:
      • 是否有申请了多余的空间,是否有更高效的解法思路来降低空间复杂度。
      • 是否在递归函数中错误地使用了传值参数导致无意义的数据拷贝。

数组

我们在说「数组」的时候有多种不同的语境,因为不同的编程语言提供的数组类型和 API 是不一样的,所以开头先统一一下说辞:可以把「数组」分为两大类,一类是「静态数组」,一类是「动态数组」。

  • 「静态数组」就是一块连续的内存空间,我们可以通过索引来访问这块内存空间中的元素,这才是数组的原始形态。
  • 「动态数组」是编程语言为了方便我们使用,在静态数组的基础上帮我们添加了一些常用的 API,比如 push, insert, remove 等等方法,这些 API 可以让我们更方便地操作数组元素,不用自己去写代码实现这些操作。

有了动态数组,后面讲到的队列、栈、哈希表等复杂数据结构都会依赖它进行实现。

静态数组

静态数组在创建的时候就要确定数组的元素类型和元素数量。只有在 C++、Java、Golang 这类语言中才提供了创建静态数组的方式,类似 Python、JavaScript 这类语言并没有提供静态数组的定义方式。

静态数组的用法比较原始,实际软件开发中很少用到,写算法题也没必要用,我们一般直接用动态数组。

定义一个静态数组的方法如下:

cpp 复制代码
// 定义一个大小为 10 的静态数组
int arr[10];

// 用 memset 函数把数组的值初始化为 0
memset(arr, 0, sizeof(arr));

// 使用索引赋值
arr[0] = 1;

// 使用索引取值
int a = arr[0];

用 memset 将数组元素初始化为 0 是 C/CPP 的特有操作。

因为在 C/CPP 中,申请静态数组的语句(即 int arr[10] )只是请操作系统在内存中开辟了一块连续的内存空间,你也不知道这块空间是谁使用过的二手内存,你也不知道里面存了什么奇奇怪怪的东西。所以一般我们会用 memset 函数把这块内存空间的值初始化一下再使用。

其他比如 Java Golang ,静态数组创建出来后会自动帮你把元素值都初始化为 0,所以不需要再显式进行初始化。

动态数组

动态数组底层还是静态数组,只是自动帮我们进行数组空间的扩缩容,并把增删查改操作进行了封装,让我们使用起来更方便而已。

因此,动态数组也不能解决静态数组在中间增删元素时效率差的问题。数组随机访问的超能力源于数组连续的内存空间,而连续的内存空间就不可避免地面对数据搬移和扩缩容的问题。

java 复制代码
// java 中创建动态数组不用显式指定数组大小,它会根据实际存储的元素数量自动扩缩容
// ArrayList 很熟悉了,即可重复的集合,底层是静态数组
ArrayList<Integer> arr = new ArrayList<>();

for (int i = 0; i < 10; i++) {
    // 在末尾追加元素,时间复杂度 O(1)
    arr.add(i);
}

// 在中间插入元素,时间复杂度 O(N)
// 在索引 2 的位置插入元素 666
arr.add(2, 666);

// 在头部插入元素,时间复杂度 O(N)
arr.add(0, -1);

// 删除末尾元素,时间复杂度 O(1)
arr.remove(arr.size() - 1);

// 删除中间元素,时间复杂度 O(N)
// 删除索引 2 的元素
arr.remove(2);

// 根据索引查询元素,时间复杂度 O(1)
int a = arr.get(0);

// 根据索引修改元素,时间复杂度 O(1)
arr.set(0, 100);

// 根据元素值查找索引,时间复杂度 O(N)
int index = arr.indexOf(666);

实现动态数组

手写实现动态数组有这几个关键点:

  • 自动扩缩容
    • 在实际使用动态数组时,缩容也是重要的优化手段。比方说一个动态数组开辟了能够存储 1000 个元素的连续内存空间,但是实际只存了 10 个元素,那就有 990 个空间是空闲的。为了避免资源浪费,我们其实可以适当缩小存储空间,这就是缩容。扩容和缩容采用的门限值可以自定义,例如:
      • 当数组元素个数达到底层静态数组的容量上限时,扩容为原来的 2 倍;
      • 当数组元素个数缩减到底层静态数组的容量的 1/4 时,缩容为原来的 1/2。
  • 索引越界的检查
    • 自然的,在删/改/查中,都有一件共同要做的事:检查下标是否越界,因此,可以将检查下标越界这个行为,再次提取出作为一个公共方法。很特殊,由于增是可以放到任意一个位置的,因此不应检查其下标是否越界
java 复制代码
 public E remove(int index) {
        // 检查索引越界
        checkElementIndex(index);
        ...
        }
  • 删除元素谨防内存泄漏
    • 单从算法的角度,其实并不需要关心被删掉的元素应该如何处理,但是具体到代码实现,我们需要注意可能出现的内存泄漏。给出的代码实现中,删除元素时,我都会把被删除的元素置为 null,
java 复制代码
// 删最后一个元素
public E removeLast() {
    E deletedVal = data[size - 1];
    // 删除最后一个元素
    // 必须给最后一个元素置为 null,否则会内存泄漏
    data[size - 1] = null;
    size--;

    return deletedVal;
}

Java 的垃圾回收机制是基于 图算法 的可达性分析,如果一个对象再也无法被访问到,那么这个对象占用的内存才会被释放;否则,垃圾回收器会认为这个对象还在使用中,就不会释放这个对象占用的内存。

如果你不执行 data[size - 1] = null 这行代码,那么 data[size - 1] 这个引用就会一直存在,你可以通过 data[size - 1] 访问这个对象,所以这个对象被认为是可达的,它的内存就一直不会被释放,进而造成内存泄漏。

单/双链表

虚拟头尾结点的使用

  • 一般来说,单链表设置虚拟头结点即可,虚拟尾结点用处不大(删改时要找到倒数第二个元素,由于没有双向指针,仍然要遍历)
  • 双链表可同时设置虚拟头尾结点
  • 设置虚拟结点有两层意思
    • 1)创建一个空的虚拟结点
    • 2)该结点用不删除,相应的引用恒指向这个空结点,如下图,同时设置了虚拟头尾结点,这两个结点恒为空,且相应的应用恒指向虚拟头尾结点

虚拟头尾结点的原理很简单,就是在创建双链表时就创建一个虚拟头节点和一个虚拟尾节点,无论双链表是否为空,这两个节点都存在。这样就不会出现空指针的问题,可以避免很多边界情况的处理。

python 复制代码
// 举例来说,假设虚拟头尾节点分别是 dummyHead 和 dummyTail,那么一条空的双链表长这样:
dummyHead <-> dummyTail

// 如果你添加 1,2,3 几个元素,那么链表长这样:
dummyHead <-> 1 <-> 2 <-> 3 <-> dummyTail

没有虚拟头尾结点时,要把在头部插入元素、在尾部插入元素和在中间插入元素几种情况分开讨论

有了头尾虚拟节点后,无论链表是否为空,都只需要考虑在中间插入元素的情况就可以了,这样代码会简洁很多。

当然,虚拟头结点会多占用一点内存空间,但是比起给你解决的麻烦,这点空间消耗是划算的。

实现(带头结点)(带头尾引用)的单链表

  • 带头结点的意思是,在创建链表之时便顺带创建一个空的结点,此即为头结点。
java 复制代码
// 单链表作为一个类->其中包含结点类->结点类中存储值以及下一个结点,逻辑闭环
public class SinglyLinkedList<E> {

    // 内部静态类:结点类,注意与单链表类区分
    private static class Node<E> {
        E val;
        Node<E> next;

        // 结点类的构造方法
        Node(E val) {
            this.val = val;
            this.next = null;
        }
    }

	// 虚拟头结点
    private Node<E> head;
	// 该尾结点指向实际尾结点,因为单链表中虚拟尾结点没用
    private Node<E> tail;
    private int size;

	// 单链表类的构造方法,该链表带有虚拟头结点,因此初始化时将头结点置空
    public SinglyLinkedList() {
        this.head = new Node<>(null);
        this.tail = head;
        this.size = 0;
    }

addFirst

  • 注意单链表中无需虚拟尾结点,因此前面定义单链表时,尾结点是实际的尾结点,因此:
  • 头引用不用动永远指向空头结点
  • 尾引用视情况改变指向
  • 且在该方法中,tail 引用只需要在链表为空时插入才要移动,其余情况不动。(因为该方法只在链头即空头结点之后插入)
java 复制代码
    public void addFirst(E e) {
        Node<E> newNode = new Node<>(e);
        // 防止断链
        newNode.next = head.next;
        // 由于是在链头(空头结点之后添加),因此用空头结点指向新结点
        head.next = newNode;
        // 如果是链表此时为空(只包含空头结点,则将尾指针指向新添加的结点)
        if (size == 0) {
            tail = newNode;
        }
        size++;
    }


addLast

这个好理解

java 复制代码
    public void addLast(E e) {
        Node<E> newNode = new Node<>(e);
        tail.next = newNode;
        tail = newNode;
        size++;
    }

add

add 方法接收传入的值和下标,将结点插入指定位置。

  1. 判断越界
  2. 判断是否在链尾插入,直接调 addLast 即可
  3. 都不是则开始遍历插入
java 复制代码
public void add(int index, E element) {
        checkPositionIndex(index);

        if (index == size) {
            addLast(element);
            return;
        }

        Node<E> prev = head;
        for (int i = 0; i < index; i++) {
            prev = prev.next;
        }
        Node<E> newNode = new Node<>(element);
        newNode.next = prev.next;
        prev.next = newNode;
        size++;
    }

removeLast

移除最后一个元素首先要找到最后一个元素,

但只找到最后一个元素是不够的,还需找到倒数第二个元素,1)将其 next 引用置空 2)tail 引用指向倒数第二个元素

java 复制代码
    public E removeLast() {
        if (isEmpty()) {
            throw new NoSuchElementException();
        }

        Node<E> prev = head;
        while (prev.next != tail) {
            prev = prev.next;
        }
        E val = tail.val;
        prev.next = null;
        tail = prev;
        size--;
        return val;
    }

实现(带虚拟头尾结点)的双链表

  • 双链表中,设置虚拟尾结点很有意义
java 复制代码
public class DoublyLinkedList<E> {
    // 虚拟头尾节点
    final private Node<E> head, tail;
    private int size;

    // 双链表节点
    private static class Node<E> {
        E val;
        Node<E> next;
        Node<E> prev;

        Node(E val) {
            this.val = val;
        }
    }

    // 构造函数初始化虚拟头尾节点
    public DoublyLinkedList() {
        this.head = new Node<>(null);
        this.tail = new Node<>(null);
        head.next = tail;
        tail.prev = head;
        this.size = 0;
    }

addFirst

  • 与单链表的 addFirst 有两个不同,修改时需要考虑进去:
    • 双链表带有 prev 指针
    • 双链表带有虚拟尾结点
java 复制代码
    public void addFirst(E e) {
        Node<E> x = new Node<>(e);
        
        // head <-> temp
        x.next = head.next;
        x.prev = head;
        head.next.prev = x;
        head.next = x;
        // head <-> x <-> temp
        
        size++;
    }

栈和队列

事实上,栈和队列本质上只是被限制了的元素进出顺序的数组/链表。

假设我们要实现的是链栈/链队,都可以用之前的双向链表调用特定 API 即可。

这里我们使用 Java 的容器 LinkedList 实现链栈:

java 复制代码
// 用链表作为底层数据结构实现栈
public class LinkedStack<E> {

    private final LinkedList<E> list = new LinkedList<>();

    // 向栈顶加入元素,时间复杂度 O(1)
    public void push(E e) {
        list.addLast(e);
    }

    // 从栈顶弹出元素,时间复杂度 O(1)
    public E pop() {
        return list.removeLast();
    }

环形数组

环形数组的提出:假设要用数组实现队列(队尾入队,队头出队),但在数组的头部改动元素复杂度是 O(n),如何优化成 O(1)?

一句话:环形数组技巧利用求模(余数)运算,将普通数组变成逻辑上的环形数组,可以让我们用 O(1) 的时间在数组头部增删元素。

  • 核心原理:
    • 环形数组维护了两个指针 start 和 end,start 指向第一个有效元素的索引,end 指向最后一个有效元素的下一个位置索引。这样,当我们在数组头部添加或删除元素时,只需要移动 start 索引,而在数组尾部添加或删除元素时,只需要移动 end 索引。
    • 当 start, end 移动超出数组边界(< 0 或 >= arr.length)时,我们可以通过求模运算 % 让它们转一圈到数组头部或尾部继续工作,这样就实现了环形数组的效果
  • 开闭区间
    • 理论上,可以随机设计区间的开闭,但一般设计为左闭右开区间是最方便处理的,即 [start, end) 区间包含数组元素。因为这样初始化 start = end = 0 时区间 [0, 0) 中没有元素,但只要让 end 向右移动(扩大)一位,区间 [0, 1) 就包含一个元素 0 了。
    • 如果设置为两端都开的区间,那么让 end 向右移动一位后开区间 (0, 1) 仍然没有元素;
    • 如果设置为两端都闭的区间,那么初始区间 [0, 0] 就已经包含了一个元素。这两种情况都会给边界处理带来不必要的麻烦,如果你非要使用的话,需要在代码中做一些特殊处理。
  • 什么时候用环形数组
    • 如果要使用数组实现的队列(特别是双端队列)时,为了保证入队出队都是 O(1),就要使用环形数组。

哈希表

  • 为什么我们常说,哈希表的增删查改效率都是 O(1)
    • 因为哈希表底层就是操作一个数组,其主要的时间复杂度来自于哈希函数计算索引和哈希冲突。只要保证哈希函数的复杂度在 O(1),且合理解决哈希冲突的问题,那么增删查改的复杂度就都是 O(1)。
  • 哈希表的遍历顺序为什么会变化?
    • 因为哈希表在达到负载因子时会扩容,这个扩容过程会导致哈希表底层的数组容量变化,哈希函数计算出来的索引也会变化,所以哈希表的遍历顺序也会变化。
  • 为什么不建议在 for 循环中增/删哈希表的 key
    • 原因和上面相同,还是扩缩容导致的哈希值变化。遍历哈希表的 key,本质就是遍历哈希表底层的 table 数组,如果一边遍历一边增删元素,如果遍历到一半,插入/删除操作触发了扩缩容,整个 table 数组都变了,那么遍历就乱套了。
      扩缩容导致 key 顺序变化是哈希表的特有行为,但即便排除这个因素,任何其他数据结构,也都不建议在遍历的过程中同时进行增删,否则很容易导致非预期的行为。
  • 为啥一定要用不可变类型作为哈希表的 key?
    • 因为哈希表的主要操作都依赖于哈希函数计算出来的索引,如果 key 的哈希值会变化,会导致键值对意外丢失,产生严重的 bug。

哈希函数的构造

谈及构造一个函数,首先要确定输入,再确定函数,最后能得到输出。

哈希函数输入

以 HashMap 为例,其 Key 只能为引用类型,即使传入 int 等基本类型,也会通过自动装箱转化为 Integer 等包装类型。

核心原因是,HashMap 会调用 hashcode 方法,将对象的引用转化为一个独一无二的数值。

因此,哈希函数的输入,即 key ,一般要求为引用类型,即 String/Integer 等。

哈希函数的构造

任意 Java 对象都会有一个 int hashCode() 方法,在实现自定义的类时,如果不重写这个方法,那么它的默认返回值可以认为是该对象的内存地址。一个对象的内存地址显然是全局唯一的一个整数。

所以我们只要调用 key 的 hashCode() 方法就相当于把 key 转化成了一个整数,且这个整数是全局唯一的。


保证索引合法

hashCode 方法返回的是 int 类型,首先一个问题就是,这个 int 值可能是负数,而数组的索引是非负整数。

那么你肯定想这样写代码,把这个值转化成非负数:

java 复制代码
int h = key.hashCode();
if (h < 0) h = -h;

但这样有问题,int 类型可以表示的最小值是【 -2^3 而最大值是 2^31 - 1。所以如果 h = -2^31,那么 -h = 2^31 就会超出 int 类型的最大值,这叫做整型溢出,编译器会报错,甚至产生不可预知的结果。

为什么 int 的最小值是 -2^31,而最大值是 2^31 - 1?这涉及计算机补码编码的原理,简单说,int 就是 32 个二进制位,其中最高位(最左边那位)是符号位,符号位是 0 时表示正数,是 1 时表示负数。

现在的问题是,我想保证 h 非负,但又不能用负号直接取反。那么一个简单直接的办法是利用这种补码编码的原理,直接把最高位的符号位变成 0,就可以保证 h 是非负数了:

哈希冲突的解决办法

如果两个不同的 key 通过哈希函数得到了相同的索引,这种情况就叫做「哈希冲突」。

哈希冲突不可能避免 ,只能在算法层面妥善处理出现哈希冲突的情况。

因为这个 hash 函数相当于是把一个无穷大的空间映射到了一个有限的索引空间,所以必然会有不同的 key 映射到同一个索引上。

哈希冲突的两种常见的解决方法,一种是拉链法,另一种是线性探查法(也经常被叫做开放寻址法)。

名字听起来高大上,说白了就是纵向延伸和横向延伸两种思路嘛:

相关推荐
BLOB_1010017 分钟前
【折腾一上午】Java POI 导出 Excel 自适应列宽行高
java·excel
酒鬼猿13 分钟前
C++初阶(七)--类和对象(4)
开发语言·c++
布值倒区什么name18 分钟前
日常记录,使用springboot,vue2,easyexcel使实现字段的匹配导入
java·spring boot·后端
你不讲 wood18 分钟前
预览 PDF 文档
开发语言·前端·javascript·pdf·html·node·文件预览
single59421 分钟前
【综合算法学习】(第十篇)
java·数据结构·c++·vscode·学习·算法·leetcode
wclass-zhengge24 分钟前
SpringBoot篇(自动装配原理)
java·spring boot·后端
蜡笔小新星24 分钟前
PyTorch的基础教程
开发语言·人工智能·pytorch·经验分享·python·深度学习·学习
gorgor在码农25 分钟前
Lua 从基础入门到精通(非常详细)
开发语言·lua
哎呦没31 分钟前
中小企业设备管理效率提升:Spring Boot系统设计
java·spring boot·后端
houyawei_NO131 分钟前
QT相机连接与拍照
开发语言·qt