Data Structure and Algorithm
Data Structure and Algorithm
Artemis一. 初识算法
1.1 什么是算法?
定义
在数学和计算机科学领域,算法是一系列有限的严谨指令,通常用于解决一类特定问题或执行计算
In mathematics and computer science, an algorithm (/ˈælɡərɪðəm/) is a finite sequence of rigorous instructions, typically used to solve a class of specific problems or to perform a computation.[^1]
Introduction to Algorithm[^2]
不正式的说,算法就是任何定义优良的计算过程:接收一些值作为输入,在有限的时间内,产生一些值作为输出。
Informally, an algorithm is any well-defined computational procedure that takes some value, or set of values, as input and produces some value, or set of values, as output in a finite amount of time.
1.2 什么是数据结构?
定义
在计算机科学领域,数据结构是一种数据组织、管理和存储格式,通常被选择用来高效访问数据
In computer science, a data structure is a data organization, management, and storage format that is usually chosen for efficient access to data
Introduction to Algorithm[^2]
数据结构是一种存储和组织数据的方式,旨在便于访问和修改
A data structure is a way to store and organize data in order to facilitate access and modifications
可以说,程序 = 数据结构 + 算法,它们是每一位程序员的基本功,下来我们通过对一个非常著名的二分查找算法的讲解来认识一下算法
1.3 二分查找 [^3]
二分查找算法也称折半查找,是一种非常高效的工作于有序数组的查找算法。后续的课程中还会学习更多的查找算法,但在此之前,不妨用它作为入门。
1) 基础版
需求:在有序数组 $A$ 内,查找值 $target$
- 如果找到返回索引
- 如果找不到返回 $-1$
算法描述
前提 | 给定一个内含 $n$ 个元素的有序数组 $A$,满足 $A_{0}\leq A_{1}\leq A_{2}\leq \cdots \leq A_{n-1}$,一个待查值 $target$ |
1 | 设置 $i=0$,$j=n-1$ |
2 | 如果 $i \gt j$,结束查找,没找到 |
3 | 设置 $m = floor(\frac {i+j}{2})$ ,$m$ 为中间索引,$floor$ 是向下取整($\leq \frac {i+j}{2}$ 的最小整数) |
4 | 如果 $target < A_{m}$ 设置 $j = m - 1$,跳到第2步 |
5 | 如果 $A_{m} < target$ 设置 $i = m + 1$,跳到第2步 |
6 | 如果 $A_{m} = target$,结束查找,找到了 |
P.S.
- 对于一个算法来讲,都有较为严谨的描述,上面是一个例子
- 后续讲解时,以简明直白为目标,不会总以上面的方式来描述算法
java 实现
1 | public static int binarySearch(int[] a, int target) { |
- $i,j$ 对应着搜索区间 $[0,a.length-1]$(注意是闭合的区间),$i<=j$ 意味着搜索区间内还有未比较的元素,$i,j$ 指向的元素也可能是比较的目标
- 思考:如果不加 $i==j$ 行不行?
- 回答:不行,因为这意味着 $i,j$ 指向的元素会漏过比较
- $m$ 对应着中间位置,中间位置左边和右边的元素可能不相等(差一个),不会影响结果
- 如果某次未找到,那么缩小后的区间内不包含 $m$
2) 改变版
另一种写法
1 | public static int binarySearch(int[] a, int target) { |
- $i,j$ 对应着搜索区间 $[0,a.length)$(注意是左闭右开的区间),$i<j$ 意味着搜索区间内还有未比较的元素,$j$ 指向的一定不是查找目标
- 思考:为啥这次不加 $i==j$ 的条件了?
- 回答:这回 $j$ 指向的不是查找目标,如果还加 $i==j$ 条件,就意味着 $j$ 指向的还会再次比较,找不到时,会死循环
- 如果某次要缩小右边界,那么 $j=m$,因为此时的 $m$ 已经不是查找目标了
1.4 衡量算法好坏
时间复杂度
下面的查找算法也能得出与之前二分查找一样的结果,那你能说出它差在哪里吗?
1 | public static int search(int[] a, int k) { |
考虑最坏情况下(没找到)例如 [1,2,3,4]
查找 5
int i = 0
只执行一次i < a.length
受数组元素个数 $n$ 的影响,比较 $n+1$ 次i++
受数组元素个数 $n$ 的影响,自增 $n$ 次a[i] == k
受元素个数 $n$ 的影响,比较 $n$ 次return -1
,执行一次
粗略认为每行代码执行时间是 $t$,假设 $n=4$ 那么
- 总执行时间是 $(1+4+1+4+4+1)*t = 15t$
- 可以推导出更一般地公式为,$T = (3*n+3)t$
如果套用二分查找算法,还是 [1,2,3,4]
查找 5
1 | public static int binarySearch(int[] a, int target) { |
int i = 0, j = a.length - 1
各执行 1 次i <= j
比较 $floor(\log_{2}(n)+1)$ 再加 1 次(i + j) >>> 1
计算 $floor(\log_{2}(n)+1)$ 次接下来
if() else if() else
会执行 $3* floor(\log_{2}(n)+1)$ 次,分别为- if 比较
- else if 比较
- else if 比较成立后的赋值语句
return -1
,执行一次
结果:
- 总执行时间为 $(2 + (1+3) + 3 + 3 * 3 +1)*t = 19t$
- 更一般地公式为 $(4 + 5 * floor(\log_{2}(n)+1))*t$
注意:
左侧未找到和右侧未找到结果不一样,这里不做分析
两个算法比较,可以看到 $n$ 在较小的时候,二者花费的次数差不多
但随着 $n$ 越来越大,比如说 $n=1000$ 时,用二分查找算法(红色)也就是 $54t$,而蓝色算法则需要 $3003t$
画图采用的是 Desmos | 图形计算器
计算机科学中,时间复杂度是用来衡量:一个算法的执行,随数据规模增大,而增长的时间成本
- 不依赖于环境因素
如何表示时间复杂度呢?
假设算法要处理的数据规模是 $n$,代码总的执行行数用函数 $f(n)$ 来表示,例如:
- 线性查找算法的函数 $f(n) = 3*n + 3$
- 二分查找算法的函数 $f(n) = (floor(log_2(n)) + 1) * 5 + 4$
为了对 $f(n)$ 进行化简,应当抓住主要矛盾,找到一个变化趋势与之相近的表示法
大 $O$ 表示法[^4]
其中
- $c, c_1, c_2$ 都为一个常数
- $f(n)$ 是实际执行代码行数与 n 的函数
- $g(n)$ 是经过化简,变化趋势与 $f(n)$ 一致的 n 的函数
渐进上界
渐进上界(asymptotic upper bound):从某个常数 $n_0$开始,$c*g(n)$ 总是位于 $f(n)$ 上方,那么记作 $O(g(n))$
- 代表算法执行的最差情况
例1
- $f(n) = 3*n+3$
- $g(n) = n$
- 取 $c=4$,在$n_0=3$ 之后,$g(n)$ 可以作为 $f(n)$ 的渐进上界,因此表示法写作 $O(n)$
例2
- $f(n) = 5*floor(log_2(n)) + 9$
- $g(n) = log_2(n)$
- $O(log_2(n))$
已知 $f(n)$ 来说,求 $g(n)$
- 表达式中相乘的常量,可以省略,如
- $f(n) = 100*n^2$ 中的 $100$
- 多项式中数量规模更小(低次项)的表达式,如
- $f(n)=n^2+n$ 中的 $n$
- $f(n) = n^3 + n^2$ 中的 $n^2$
- 不同底数的对数,渐进上界可以用一个对数函数 $\log n$ 表示
- 例如:$log_2(n)$ 可以替换为 $log_{10}(n)$,因为 $log_2(n) = \frac{log_{10}(n)}{log_{10}(2)}$,相乘的常量 $\frac{1}{log_{10}(2)}$ 可以省略
- 类似的,对数的常数次幂可省略
- 如:$log(n^c) = c * log(n)$
常见大 $O$ 表示法
按时间复杂度从低到高
- 黑色横线 $O(1)$,常量时间,意味着算法时间并不随数据规模而变化
- 绿色 $O(log(n))$,对数时间
- 蓝色 $O(n)$,线性时间,算法时间与数据规模成正比
- 橙色 $O(n*log(n))$,拟线性时间
- 红色 $O(n^2)$ 平方时间
- 黑色朝上 $O(2^n)$ 指数时间
- 没画出来的 $O(n!)$
渐进下界
渐进下界(asymptotic lower bound):从某个常数 $n_0$开始,$c*g(n)$ 总是位于 $f(n)$ 下方,那么记作 $\Omega(g(n))$
渐进紧界
渐进紧界(asymptotic tight bounds):从某个常数 $n_0$开始,$f(n)$ 总是在 $c_1g(n)$ 和 $c_2g(n)$ 之间,那么记作 $\Theta(g(n))$
空间复杂度
与时间复杂度类似,一般也使用大 $O$ 表示法来衡量:一个算法执行随数据规模增大,而增长的额外空间成本
1 | public static int binarySearchBasic(int[] a, int target) { |
二分查找性能
下面分析二分查找算法的性能
时间复杂度
- 最坏情况:$O(\log n)$
- 最好情况:如果待查找元素恰好在数组中央,只需要循环一次 $O(1)$
空间复杂度
- 需要常数个指针 $i,j,m$,因此额外占用的空间是 $O(1)$
1.5 再看二分查找
1) 平衡版
1 | public static int binarySearchBalance(int[] a, int target) { |
思想:
- 左闭右开的区间,$i$ 指向的可能是目标,而 $j$ 指向的不是目标
- 不奢望循环内通过 $m$ 找出目标, 缩小区间直至剩 1 个, 剩下的这个可能就是要找的(通过 $i$)
- $j - i > 1$ 的含义是,在范围内待比较的元素个数 > 1
- 改变 $i$ 边界时,它指向的可能是目标,因此不能 $m+1$
- 循环内的平均比较次数减少了
- 时间复杂度 $\Theta(log(n))$
2) Java 版
1 | private static int binarySearch0(long[] a, int fromIndex, int toIndex, |
- 例如 $[1,3,5,6]$ 要插入 $2$ 那么就是找到一个位置,这个位置左侧元素都比它小
- 等循环结束,若没找到,low 左侧元素肯定都比 target 小,因此 low 即插入点
- 插入点取负是为了与找到情况区分
- -1 是为了把索引 0 位置的插入点与找到的情况进行区分
3) Leftmost 与 Rightmost
有时我们希望返回的是最左侧的重复元素,如果用 Basic 二分查找
对于数组 $[1, 2, 3, 4, 4, 5, 6, 7]$,查找元素4,结果是索引3
对于数组 $[1, 2, 4, 4, 4, 5, 6, 7]$,查找元素4,结果也是索引3,并不是最左侧的元素
1 | public static int binarySearchLeftmost1(int[] a, int target) { |
如果希望返回的是最右侧元素
1 | public static int binarySearchRightmost1(int[] a, int target) { |
应用
对于 Leftmost 与 Rightmost,可以返回一个比 -1 更有用的值
Leftmost 改为
1 | public static int binarySearchLeftmost(int[] a, int target) { |
- leftmost 返回值的另一层含义:$\lt target$ 的元素个数
- 小于等于中间值,都要向左找
Rightmost 改为
1 | public static int binarySearchRightmost(int[] a, int target) { |
- 大于等于中间值,都要向右找
几个名词
范围查询:
- 查询 $x \lt 4$,$0 .. leftmost(4) - 1$
- 查询 $x \leq 4$,$0 .. rightmost(4)$
- 查询 $4 \lt x$,$rightmost(4) + 1 .. \infty $
- 查询 $4 \leq x$, $leftmost(4) .. \infty$
- 查询 $4 \leq x \leq 7$,$leftmost(4) .. rightmost(7)$
- 查询 $4 \lt x \lt 7$,$rightmost(4)+1 .. leftmost(7)-1$
求排名:$leftmost(target) + 1$
- $target$ 可以不存在,如:$leftmost(5)+1 = 6$
- $target$ 也可以存在,如:$leftmost(4)+1 = 3$
求前任(predecessor):$leftmost(target) - 1$
- $leftmost(3) - 1 = 1$,前任 $a_1 = 2$
- $leftmost(4) - 1 = 1$,前任 $a_1 = 2$
求后任(successor):$rightmost(target)+1$
- $rightmost(5) + 1 = 5$,后任 $a_5 = 7$
- $rightmost(4) + 1 = 5$,后任 $a_5 = 7$
求最近邻居:
- 前任和后任距离更近者
习题
1) 时间复杂度估算
用函数 $f(n)$ 表示算法效率与数据规模的关系,假设每次解决问题需要 1 微秒($10^{-6}$ 秒),进行估算:
- 如果 $f(n) = n^2$ 那么 1 秒能解决多少次问题?1 天呢?
- 如果 $f(n) = log_2(n)$ 那么 1 秒能解决多少次问题?1 天呢?
- 如果 $f(n) = n!$ 那么 1 秒能解决多少次问题?1 天呢?
参考解答
- 1秒 $\sqrt{10^6} = 1000$ 次,1 天 $\sqrt{10^6 * 3600 * 24} \approx 293938$ 次
- 1秒 $2^{1,000,000} $ 次,一天 $2^{86,400,000,000}$
- 推算如下
- $10! = 3,628,800$ 1秒能解决 $1,000,000$ 次,因此次数为 9 次
- $14!=87,178,291,200$,一天能解决 $86,400,000,000$ 次,因此次数为 13 次
2) 耗时估算
一台机器对200个单词进行排序花了200秒(使用冒泡排序),那么花费800秒,大概可以对多少个单词进行排序
a. 400
b. 600
c. 800
d. 1600
答案
- a
解释
- 冒泡排序时间复杂度是 $O(N^2)$
- 时间增长 4 倍,而因此能处理的数据量是原来的 $\sqrt{4} = 2$ 倍
3) E01. 二分查找-Leetcode 704
要点:减而治之,可以用递归或非递归实现
给定一个 n 个元素有序的(升序)整型数组 nums 和一个目标值 target ,写一个函数搜索 nums 中的 target,如果目标值存在返回下标,否则返回 -1
例如
1 | 输入: nums = [-1,0,3,5,9,12], target = 9 |
参考答案:略,可以用讲过的任意一种二分求解
4) E02. 搜索插入位置-Leetcode 35
要点:理解谁代表插入位置
给定一个排序数组和一个目标值
- 在数组中找到目标值,并返回其索引
- 如果目标值不存在于数组中,返回它将会被按顺序插入的位置
例如
1 | 输入: nums = [1,3,5,6], target = 5 |
参考答案1:用二分查找基础版代码改写,基础版中,找到返回 m,没找到 i 代表插入点,因此有
1 | public int searchInsert(int[] a, int target) { |
参考答案2:用二分查找平衡版改写,平衡版中
- 如果 target == a[i] 返回 i 表示找到
- 如果 target < a[i],例如 target = 2,a[i] = 3,这时就应该在 i 位置插入 2
- 如果 a[i] < target,例如 a[i] = 3,target = 4,这时就应该在 i+1 位置插入 4
1 | public static int searchInsert(int[] a, int target) { |
参考答案3:用 leftmost 版本解,返回值即为插入位置(并能处理元素重复的情况)
1 | public int searchInsert(int[] a, int target) { |
5) E03. 搜索开始结束位置-Leetcode 34
给你一个按照非递减顺序排列的整数数组 nums,和一个目标值 target。请你找出给定目标值在数组中的开始位置和结束位置。
如果数组中不存在目标值 target,返回 [-1, -1]。
你必须设计并实现时间复杂度为 O(log n) 的算法解决此问题
例如
1 | 输入:nums = [5,7,7,8,8,10], target = 8 |
参考答案
1 | public static int left(int[] a, int target) { |
二. 基础数据结构
2.1 数组
1) 概述
定义
在计算机科学中,数组是由一组元素(值或变量)组成的数据结构,每个元素有至少一个索引或键来标识
In computer science, an array is a data structure consisting of a collection of elements (values or variables), each identified by at least one array index or key
因为数组内的元素是连续存储的,所以数组中元素的地址,可以通过其索引计算出来,例如:
1 | int[] array = {1,2,3,4,5} |
知道了数组的数据起始地址 $BaseAddress$,就可以由公式 $BaseAddress + i * size$ 计算出索引 $i$ 元素的地址
- $i$ 即索引,在 Java、C 等语言都是从 0 开始
- $size$ 是每个元素占用字节,例如 $int$ 占 $4$,$double$ 占 $8$
小测试
1 | byte[] array = {1,2,3,4,5} |
已知 array 的数据的起始地址是 0x7138f94c8,那么元素 3 的地址是什么?
答:0x7138f94c8 + 2 * 1 = 0x7138f94ca
空间占用
Java 中数组结构为
- 8 字节 markword
- 4 字节 class 指针(压缩 class 指针的情况)
- 4 字节 数组大小(决定了数组最大容量是 $2^{32}$)
- 数组元素 + 对齐字节(java 中所有对象大小都是 8 字节的整数倍[^12],不足的要用对齐字节补足)
例如
1 | int[] array = {1, 2, 3, 4, 5}; |
的大小为 40 个字节,组成如下
1 | 8 + 4 + 4 + 5*4 + 4(alignment) |
随机访问性能
即根据索引查找元素,时间复杂度是 $O(1)$
2) 动态数组
java 版本
1 | public class DynamicArray implements Iterable<Integer> { |
- 这些方法实现,都简化了 index 的有效性判断,假设输入的 index 都是合法的
插入或删除性能
头部位置,时间复杂度是 $O(n)$
中间位置,时间复杂度是 $O(n)$
尾部位置,时间复杂度是 $O(1)$(均摊来说)
3) 二维数组
1 | int[][] array = { |
内存图如下
二维数组占 32 个字节,其中 array[0],array[1],array[2] 三个元素分别保存了指向三个一维数组的引用
三个一维数组各占 40 个字节
它们在内层布局上是连续的
更一般的,对一个二维数组 $Array[m][n]$
- $m$ 是外层数组的长度,可以看作 row 行
- $n$ 是内层数组的长度,可以看作 column 列
- 当访问 $Array[i][j]$,$0\leq i \lt m, 0\leq j \lt n$时,就相当于
- 先找到第 $i$ 个内层数组(行)
- 再找到此内层数组中第 $j$ 个元素(列)
小测试
Java 环境下(不考虑类指针和引用压缩,此为默认情况),有下面的二维数组
1 | byte[][] array = { |
已知 array 对象起始地址是 0x1000,那么 23 这个元素的地址是什么?
答:
- 起始地址 0x1000
- 外层数组大小:16字节对象头 + 3元素 * 每个引用4字节 + 4 对齐字节 = 32 = 0x20
- 第一个内层数组大小:16字节对象头 + 5元素 * 每个byte1字节 + 3 对齐字节 = 24 = 0x18
- 第二个内层数组,16字节对象头 = 0x10,待查找元素索引为 2
- 最后结果 = 0x1000 + 0x20 + 0x18 + 0x10 + 2*1 = 0x104a
4) 局部性原理
这里只讨论空间局部性
- cpu 读取内存(速度慢)数据后,会将其放入高速缓存(速度快)当中,如果后来的计算再用到此数据,在缓存中能读到的话,就不必读内存了
- 缓存的最小存储单位是缓存行(cache line),一般是 64 bytes,一次读的数据少了不划算啊,因此最少读 64 bytes 填满一个缓存行,因此读入某个数据时也会读取其临近的数据,这就是所谓空间局部性
对效率的影响
比较下面 ij 和 ji 两个方法的执行效率
1 | int rows = 1000000; |
ij 方法
1 | public static void ij(int[][] a, int rows, int columns) { |
ji 方法
1 | public static void ji(int[][] a, int rows, int columns) { |
执行结果
1 | 0 |
可以看到 ij 的效率比 ji 快很多,为什么呢?
- 缓存是有限的,当新数据来了后,一些旧的缓存行数据就会被覆盖
- 如果不能充分利用缓存的数据,就会造成效率低下
以 ji 执行为例,第一次内循环要读入 $[0,0]$ 这条数据,由于局部性原理,读入 $[0,0]$ 的同时也读入了 $[0,1] … [0,13]$,如图所示
但很遗憾,第二次内循环要的是 $[1,0]$ 这条数据,缓存中没有,于是再读入了下图的数据
这显然是一种浪费,因为 $[0,1] … [0,13]$ 包括 $[1,1] … [1,13]$ 这些数据虽然读入了缓存,却没有及时用上,而缓存的大小是有限的,等执行到第九次内循环时
缓存的第一行数据已经被新的数据 $[8,0] … [8,13]$ 覆盖掉了,以后如果再想读,比如 $[0,1]$,又得到内存去读了
同理可以分析 ij 函数则能充分利用局部性原理加载到的缓存数据
举一反三
I/O 读写时同样可以体现局部性原理
数组可以充分利用局部性原理,那么链表呢?
答:链表不行,因为链表的元素并非相邻存储
5) 越界检查
java 中对数组元素的读写都有越界检查,类似于下面的代码
1 | bool is_within_bounds(int index) const |
- 代码位置:
openjdk\src\hotspot\share\oops\arrayOop.hpp
只不过此检查代码,不需要由程序员自己来调用,JVM 会帮我们调用
习题
E01. 合并有序数组 - 对应 Leetcode 88
将数组内两个区间内的有序元素合并
例
1 | [1, 5, 6, 2, 4, 10, 11] |
可以视作两个有序区间
1 | [1, 5, 6] 和 [2, 4, 10, 11] |
合并后,结果仍存储于原有空间
1 | [1, 2, 4, 5, 6, 10, 11] |
方法1
递归
- 每次递归把更小的元素复制到结果数组
1 | merge(left=[1,5,6],right=[2,4,10,11],a2=[]){ |
代码
1 | public static void merge(int[] a1, int i, int iEnd, int j, int jEnd, |
测试
1 | int[] a1 = {1, 5, 6, 2, 4, 10, 11}; |
方法2
代码
1 | public static void merge(int[] a1, int i, int iEnd, |
测试
1 | int[] a1 = {1, 5, 6, 2, 4, 10, 11}; |
2.2 链表
1) 概述
定义
在计算机科学中,链表是数据元素的线性集合,其每个元素都指向下一个元素,元素存储上并不连续
In computer science, a linked list is a linear collection of data elements whose order is not given by their physical placement in memory. Instead, each element points to the next.
可以分类为[^5]
- 单向链表,每个元素只知道其下一个元素是谁
- 双向链表,每个元素知道其上一个元素和下一个元素
- 循环链表,通常的链表尾节点 tail 指向的都是 null,而循环链表的 tail 指向的是头节点 head
链表内还有一种特殊的节点称为哨兵(Sentinel)节点,也叫做哑元( Dummy)节点,它不存储数据,通常用作头尾,用来简化边界判断,如下图所示
随机访问性能
根据 index 查找,时间复杂度 $O(n)$
插入或删除性能
- 起始位置:$O(1)$
- 结束位置:如果已知 tail 尾节点是 $O(1)$,不知道 tail 尾节点是 $O(n)$
- 中间位置:根据 index 查找时间 + $O(1)$
2) 单向链表
根据单向链表的定义,首先定义一个存储 value 和 next 指针的类 Node,和一个描述头部节点的引用
1 | public class SinglyLinkedList { |
- Node 定义为内部类,是为了对外隐藏实现细节,没必要让类的使用者关心 Node 结构
- 定义为 static 内部类,是因为 Node 不需要与 SinglyLinkedList 实例相关,多个 SinglyLinkedList实例能共用 Node 类定义
头部添加
1 | public class SinglyLinkedList { |
- 如果 this.head == null,新增节点指向 null,并作为新的 this.head
- 如果 this.head != null,新增节点指向原来的 this.head,并作为新的 this.head
- 注意赋值操作执行顺序是从右到左
while 遍历
1 | public class SinglyLinkedList { |
for 遍历
1 | public class SinglyLinkedList { |
- 以上两种遍历都可以把要做的事以 Consumer 函数的方式传递进来
- Consumer 的规则是一个参数,无返回值,因此像 System.out::println 方法等都是 Consumer
- 调用 Consumer 时,将当前节点 curr.value 作为参数传递给它
迭代器遍历
1 | public class SinglyLinkedList implements Iterable<Integer> { |
- hasNext 用来判断是否还有必要调用 next
- next 做两件事
- 返回当前节点的 value
- 指向下一个节点
- NodeIterator 要定义为非 static 内部类,是因为它与 SinglyLinkedList 实例相关,是对某个 SinglyLinkedList 实例的迭代
递归遍历
1 | public class SinglyLinkedList implements Iterable<Integer> { |
尾部添加
1 | public class SinglyLinkedList { |
- 注意,找最后一个节点,终止条件是 curr.next == null
- 分成两个方法是为了代码清晰,而且 findLast() 之后还能复用
尾部添加多个
1 | public class SinglyLinkedList { |
- 先串成一串 sublist
- 再作为一个整体添加
根据索引获取
1 | public class SinglyLinkedList { |
- 同样,分方法可以实现复用
插入
1 | public class SinglyLinkedList { |
- 插入包括下面的删除,都必须找到上一个节点
删除
1 | public class SinglyLinkedList { |
- 第一个 if 块对应着 removeFirst 情况
- 最后一个 if 块对应着至少得两个节点的情况
- 不仅仅判断上一个节点非空,还要保证当前节点非空
3) 单向链表(带哨兵)
观察之前单向链表的实现,发现每个方法内几乎都有判断是不是 head 这样的代码,能不能简化呢?
用一个不参与数据存储的特殊 Node 作为哨兵,它一般被称为哨兵或哑元,拥有哨兵节点的链表称为带头链表
1 | public class SinglyLinkedListSentinel { |
- 具体存什么值无所谓,因为不会用到它的值
加入哨兵节点后,代码会变得比较简单,先看几个工具方法
1 | public class SinglyLinkedListSentinel { |
- findNode 与之前类似,只是 i 初始值设置为 -1 对应哨兵,实际传入的 index 也是 $[-1, \infty)$
- findLast 绝不会返回 null 了,就算没有其它节点,也会返回哨兵作为最后一个节点
这样,代码简化为
1 | public class SinglyLinkedListSentinel { |
- 对于删除,前面说了【最后一个 if 块对应着至少得两个节点的情况】,现在有了哨兵,就凑足了两个节点
4) 双向链表(带哨兵)
1 | public class DoublyLinkedListSentinel implements Iterable<Integer> { |
5) 环形链表(带哨兵)
双向环形链表带哨兵,这时哨兵既作为头,也作为尾
参考实现
1 | public class DoublyLinkedListSentinel implements Iterable<Integer> { |