数据结构 Data Structure and Algorithm Artemis 2024-11-16 2024-11-16 一. 初识算法 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$
算法描述
前提
给定一个内含 $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 2 3 4 5 6 7 8 9 10 11 12 13 14 public static int binarySearch (int [] a, int target) { int i = 0 , j = a.length - 1 ; while (i <= j) { int m = (i + j) >>> 1 ; if (target < a[m]) { j = m - 1 ; } else if (a[m] < target) { i = m + 1 ; } else { return m; } } return -1 ; }
$i,j$ 对应着搜索区间 $[0,a.length-1]$(注意是闭合的区间),$i<=j$ 意味着搜索区间内还有未比较的元素,$i,j$ 指向的元素也可能是比较的目标
思考:如果不加 $i==j$ 行不行?
回答:不行,因为这意味着 $i,j$ 指向的元素会漏过比较
$m$ 对应着中间位置,中间位置左边和右边的元素可能不相等(差一个),不会影响结果
如果某次未找到,那么缩小后的区间内不包含 $m$
2) 改变版 另一种写法
1 2 3 4 5 6 7 8 9 10 11 12 13 14 public static int binarySearch (int [] a, int target) { int i = 0 , j = a.length; while (i < j) { int m = (i + j) >>> 1 ; if (target < a[m]) { j = m; } else if (a[m] < target) { i = m + 1 ; } else { return m; } } return -1 ; }
$i,j$ 对应着搜索区间 $[0,a.length)$(注意是左闭右开的区间),$i<j$ 意味着搜索区间内还有未比较的元素,$j$ 指向的一定不是 查找目标
思考:为啥这次不加 $i==j$ 的条件了?
回答:这回 $j$ 指向的不是查找目标,如果还加 $i==j$ 条件,就意味着 $j$ 指向的还会再次比较,找不到时,会死循环
如果某次要缩小右边界,那么 $j=m$,因为此时的 $m$ 已经不是 查找目标了
1.4 衡量算法好坏 时间复杂度
下面的查找算法也能得出与之前二分查找一样的结果,那你能说出它差在哪里吗?
1 2 3 4 5 6 7 8 9 10 11 12 public static int search (int [] a, int k) { for ( int i = 0 ; i < a.length; i++ ) { if (a[i] == k) { return i; } } return -1 ; }
考虑最坏情况下(没找到)例如 [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 2 3 4 5 6 7 8 9 10 11 12 13 14 public static int binarySearch (int [] a, int target) { int i = 0 , j = a.length - 1 ; while (i <= j) { int m = (i + j) >>> 1 ; if (target < a[m]) { j = m - 1 ; } else if (a[m] < target) { i = m + 1 ; } else { return m; } } return -1 ; }
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 | 图形计算器
计算机科学中,时间复杂度 是用来衡量:一个算法的执行,随数据规模增大,而增长的时间成本
如何表示时间复杂度呢?
大 $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_2 g(n)$ 之间,那么记作 $\Theta(g(n))$
空间复杂度
与时间复杂度类似,一般也使用大 $O$ 表示法来衡量:一个算法执行随数据规模增大,而增长的额外 空间成本
1 2 3 4 5 6 7 8 9 10 11 12 13 14 public static int binarySearchBasic (int [] a, int target) { int i = 0 , j = a.length - 1 ; while (i <= j) { int m = (i + j) >>> 1 ; if (target < a[m]) { j = m - 1 ; } else if (a[m] < target) { i = m + 1 ; } else { return m; } } return -1 ; }
二分查找性能
下面分析二分查找算法的性能
时间复杂度
最坏情况:$O(\log n)$
最好情况:如果待查找元素恰好在数组中央,只需要循环一次 $O(1)$
空间复杂度
需要常数个指针 $i,j,m$,因此额外占用的空间是 $O(1)$
1.5 再看二分查找 1) 平衡版 1 2 3 4 5 6 7 8 9 10 11 12 public static int binarySearchBalance (int [] a, int target) { int i = 0 , j = a.length; while (1 < j - i) { int m = (i + j) >>> 1 ; if (target < a[m]) { j = m; } else { i = m; } } return (a[i] == target) ? i : -1 ; }
思想:
左闭右开的区间,$i$ 指向的可能是目标,而 $j$ 指向的不是目标
不奢望循环内通过 $m$ 找出目标, 缩小区间直至剩 1 个, 剩下的这个可能就是要找的(通过 $i$)
$j - i > 1$ 的含义是,在范围内待比较的元素个数 > 1
改变 $i$ 边界时,它指向的可能是目标,因此不能 $m+1$
循环内的平均比较次数减少了
时间复杂度 $\Theta(log(n))$
2) Java 版 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 private static int binarySearch0 (long [] a, int fromIndex, int toIndex, long key) { int low = fromIndex; int high = toIndex - 1 ; while (low <= high) { int mid = (low + high) >>> 1 ; long midVal = a[mid]; if (midVal < key) low = mid + 1 ; else if (midVal > key) high = mid - 1 ; else return mid; } return -(low + 1 ); }
例如 $[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 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 public static int binarySearchLeftmost1 (int [] a, int target) { int i = 0 , j = a.length - 1 ; int candidate = -1 ; while (i <= j) { int m = (i + j) >>> 1 ; if (target < a[m]) { j = m - 1 ; } else if (a[m] < target) { i = m + 1 ; } else { candidate = m; j = m - 1 ; } } return candidate; }
如果希望返回的是最右侧元素
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 public static int binarySearchRightmost1 (int [] a, int target) { int i = 0 , j = a.length - 1 ; int candidate = -1 ; while (i <= j) { int m = (i + j) >>> 1 ; if (target < a[m]) { j = m - 1 ; } else if (a[m] < target) { i = m + 1 ; } else { candidate = m; i = m + 1 ; } } return candidate; }
应用
对于 Leftmost 与 Rightmost,可以返回一个比 -1 更有用的值
Leftmost 改为
1 2 3 4 5 6 7 8 9 10 11 12 public static int binarySearchLeftmost (int [] a, int target) { int i = 0 , j = a.length - 1 ; while (i <= j) { int m = (i + j) >>> 1 ; if (target <= a[m]) { j = m - 1 ; } else { i = m + 1 ; } } return i; }
leftmost 返回值的另一层含义:$\lt target$ 的元素个数
小于等于中间值,都要向左找
Rightmost 改为
1 2 3 4 5 6 7 8 9 10 11 12 public static int binarySearchRightmost (int [] a, int target) { int i = 0 , j = a.length - 1 ; while (i <= j) { int m = (i + j) >>> 1 ; if (target < a[m]) { j = m - 1 ; } else { i = m + 1 ; } } return i - 1 ; }
几个名词
范围查询 :
查询 $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
答案
解释
冒泡排序时间复杂度是 $O(N^2)$
时间增长 4 倍,而因此能处理的数据量是原来的 $\sqrt{4} = 2$ 倍
3) E01. 二分查找-Leetcode 704 要点 :减而治之,可以用递归或非递归实现
给定一个 n 个元素有序的(升序)整型数组 nums 和一个目标值 target ,写一个函数搜索 nums 中的 target,如果目标值存在返回下标,否则返回 -1
例如
1 2 3 4 5 6 7 输入: nums = [-1 ,0 ,3 ,5 ,9 ,12 ], target = 9 输出: 4 解释: 9 出现在 nums 中并且下标为 4 输入: nums = [-1 ,0 ,3 ,5 ,9 ,12 ], target = 2 输出: -1 解释: 2 不存在 nums 中因此返回 -1
参考答案 :略,可以用讲过的任意一种二分求解
4) E02. 搜索插入位置-Leetcode 35 要点 :理解谁代表插入位置
给定一个排序数组和一个目标值
在数组中找到目标值,并返回其索引
如果目标值不存在于数组中,返回它将会被按顺序插入的位置
例如
1 2 3 4 5 6 7 8 输入: nums = [1,3,5,6], target = 5 输出: 2 输入: nums = [1,3,5,6], target = 2 输出: 1 输入: nums = [1,3,5,6], target = 7 输出: 4
参考答案1 :用二分查找基础版代码改写,基础版中,找到返回 m,没找到 i 代表插入点,因此有
1 2 3 4 5 6 7 8 9 10 11 12 13 14 public int searchInsert (int [] a, int target) { int i = 0 , j = a.length - 1 ; while (i <= j) { int m = (i + j) >>> 1 ; if (target < a[m]) { j = m - 1 ; } else if (a[m] < target) { i = m + 1 ; } else { return m; } } return i; }
参考答案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 2 3 4 5 6 7 8 9 10 11 12 13 public static int searchInsert (int [] a, int target) { int i = 0 , j = a.length; while (1 < j - i) { int m = (i + j) >>> 1 ; if (target < a[m]) { j = m; } else { i = m; } } return (target <= a[i]) ? i : i + 1 ; }
参考答案3 :用 leftmost 版本解,返回值即为插入位置(并能处理元素重复的情况)
1 2 3 4 5 6 7 8 9 10 11 12 public int searchInsert (int [] a, int target) { int i = 0 , j = a.length - 1 ; while (i <= j) { int m = (i + j) >>> 1 ; if (target <= a[m]) { j = m - 1 ; } else { i = m + 1 ; } } return i; }
5) E03. 搜索开始结束位置-Leetcode 34 给你一个按照非递减顺序排列的整数数组 nums,和一个目标值 target。请你找出给定目标值在数组中的开始位置和结束位置。
如果数组中不存在目标值 target,返回 [-1, -1]。
你必须设计并实现时间复杂度为 O(log n) 的算法解决此问题
例如
1 2 3 4 5 6 7 8 输入:nums = [5,7,7,8,8,10], target = 8 输出:[3,4] 输入:nums = [5,7,7,8,8,10], target = 6 输出:[-1,-1] 输入:nums = [], target = 0 输出:[-1,-1]
参考答案
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 public static int left (int [] a, int target) { int i = 0 , j = a.length - 1 ; int candidate = -1 ; while (i <= j) { int m = (i + j) >>> 1 ; if (target < a[m]) { j = m - 1 ; } else if (a[m] < target) { i = m + 1 ; } else { candidate = m; j = m - 1 ; } } return candidate; } public static int right (int [] a, int target) { int i = 0 , j = a.length - 1 ; int candidate = -1 ; while (i <= j) { int m = (i + j) >>> 1 ; if (target < a[m]) { j = m - 1 ; } else if (a[m] < target) { i = m + 1 ; } else { candidate = m; i = m + 1 ; } } return candidate; } public static int [] searchRange(int [] nums, int target) { int x = left(nums, target); if (x == -1 ) { return new int [] {-1 , -1 }; } else { return new int [] {x, right(nums, 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 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 public class DynamicArray implements Iterable <Integer> { private int size = 0 ; private int capacity = 8 ; private int [] array = {}; public void addLast (int element) { add(size, element); } public void add (int index, int element) { checkAndGrow(); if (index >= 0 && index < size) { System.arraycopy(array, index, array, index + 1 , size - index); } array[index] = element; size++; } private void checkAndGrow () { if (size == 0 ) { array = new int [capacity]; } else if (size == capacity) { capacity += capacity >> 1 ; int [] newArray = new int [capacity]; System.arraycopy(array, 0 , newArray, 0 , size); array = newArray; } } public int remove (int index) { int removed = array[index]; if (index < size - 1 ) { System.arraycopy(array, index + 1 , array, index, size - index - 1 ); } size--; return removed; } public int get (int index) { return array[index]; } public void foreach (Consumer<Integer> consumer) { for (int i = 0 ; i < size; i++) { consumer.accept(array[i]); } } @Override public Iterator<Integer> iterator () { return new Iterator <Integer>() { int i = 0 ; @Override public boolean hasNext () { return i < size; } @Override public Integer next () { return array[i++]; } }; } public IntStream stream () { return IntStream.of(Arrays.copyOfRange(array, 0 , size)); } }
这些方法实现,都简化了 index 的有效性判断,假设输入的 index 都是合法的
插入或删除性能
头部位置,时间复杂度是 $O(n)$
中间位置,时间复杂度是 $O(n)$
尾部位置,时间复杂度是 $O(1)$(均摊来说)
3) 二维数组 1 2 3 4 5 int [][] array = { {11 , 12 , 13 , 14 , 15 }, {21 , 22 , 23 , 24 , 25 }, {31 , 32 , 33 , 34 , 35 }, };
内存图如下
更一般的,对一个二维数组 $Array[m][n]$
$m$ 是外层数组的长度,可以看作 row 行
$n$ 是内层数组的长度,可以看作 column 列
当访问 $Array[i][j]$,$0\leq i \lt m, 0\leq j \lt n$时,就相当于
先找到第 $i$ 个内层数组(行)
再找到此内层数组中第 $j$ 个元素(列)
小测试
Java 环境下(不考虑类指针和引用压缩,此为默认情况),有下面的二维数组
1 2 3 4 5 byte [][] array = { {11 , 12 , 13 , 14 , 15 }, {21 , 22 , 23 , 24 , 25 }, {31 , 32 , 33 , 34 , 35 }, };
已知 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 2 3 4 5 6 7 8 9 10 11 12 int rows = 1000000 ;int columns = 14 ;int [][] a = new int [rows][columns];StopWatch sw = new StopWatch ();sw.start("ij" ); ij(a, rows, columns); sw.stop(); sw.start("ji" ); ji(a, rows, columns); sw.stop(); System.out.println(sw.prettyPrint());
ij 方法
1 2 3 4 5 6 7 8 9 public static void ij (int [][] a, int rows, int columns) { long sum = 0L ; for (int i = 0 ; i < rows; i++) { for (int j = 0 ; j < columns; j++) { sum += a[i][j]; } } System.out.println(sum); }
ji 方法
1 2 3 4 5 6 7 8 9 public static void ji (int [][] a, int rows, int columns) { long sum = 0L ; for (int j = 0 ; j < columns; j++) { for (int i = 0 ; i < rows; i++) { sum += a[i][j]; } } System.out.println(sum); }
执行结果
1 2 3 4 5 6 7 8 0 0 StopWatch '': running time = 96283300 ns --------------------------------------------- ns % Task name --------------------------------------------- 016196200 017% ij 080087100 083% ji
可以看到 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 2 3 4 bool is_within_bounds (int index) const { return 0 <= index && index < length (); }
代码位置:openjdk\src\hotspot\share\oops\arrayOop.hpp
只不过此检查代码,不需要由程序员自己来调用,JVM 会帮我们调用
习题 E01. 合并有序数组 - 对应 Leetcode 88 将数组内两个区间内的有序元素合并
例
可以视作两个有序区间
1 [1, 5, 6] 和 [2, 4, 10, 11]
合并后,结果仍存储于原有空间
方法1
递归
1 2 3 4 5 6 7 8 9 10 11 12 13 merge(left=[1 ,5 ,6 ],right=[2 ,4 ,10 ,11 ],a2=[]){ merge(left=[5 ,6 ],right=[2 ,4 ,10 ,11 ],a2=[1 ]){ merge(left=[5 ,6 ],right=[4 ,10 ,11 ],a2=[1 ,2 ]){ merge(left=[5 ,6 ],right=[10 ,11 ],a2=[1 ,2 ,4 ]){ merge(left=[6 ],right=[10 ,11 ],a2=[1 ,2 ,4 ,5 ]){ merge(left=[],right=[10 ,11 ],a2=[1 ,2 ,4 ,5 ,6 ]){ } } } } } }
代码
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 public static void merge (int [] a1, int i, int iEnd, int j, int jEnd, int [] a2, int k) { if (i > iEnd) { System.arraycopy(a1, j, a2, k, jEnd - j + 1 ); return ; } if (j > jEnd) { System.arraycopy(a1, i, a2, k, iEnd - i + 1 ); return ; } if (a1[i] < a1[j]) { a2[k] = a1[i]; merge(a1, i + 1 , iEnd, j, jEnd, a2, k + 1 ); } else { a2[k] = a1[j]; merge(a1, i, iEnd, j + 1 , jEnd, a2, k + 1 ); } }
测试
1 2 3 int [] a1 = {1 , 5 , 6 , 2 , 4 , 10 , 11 };int [] a2 = new int [a1.length];merge(a1, 0 , 2 , 3 , 6 , a2, 0 );
方法2
代码
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 public static void merge (int [] a1, int i, int iEnd, int j, int jEnd, int [] a2) { int k = i; while (i <= iEnd && j <= jEnd) { if (a1[i] < a1[j]) { a2[k] = a1[i]; i++; } else { a2[k] = a1[j]; j++; } k++; } if (i > iEnd) { System.arraycopy(a1, j, a2, k, jEnd - j + 1 ); } if (j > jEnd) { System.arraycopy(a1, i, a2, k, iEnd - i + 1 ); } }
测试
1 2 3 int [] a1 = {1 , 5 , 6 , 2 , 4 , 10 , 11 };int [] a2 = new int [a3.length];merge(a1, 0 , 2 , 3 , 6 , a2);
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 2 3 4 5 6 7 8 9 10 11 12 13 14 public class SinglyLinkedList { private Node head; private static class Node { int value; Node next; public Node (int value, Node next) { this .value = value; this .next = next; } } }
Node 定义为内部类,是为了对外隐藏 实现细节,没必要让类的使用者关心 Node 结构
定义为 static 内部类,是因为 Node 不需要 与 SinglyLinkedList 实例相关,多个 SinglyLinkedList实例能共用 Node 类定义
头部添加
1 2 3 4 5 6 public class SinglyLinkedList { public void addFirst (int value) { this .head = new Node (value, this .head); } }
如果 this.head == null,新增节点指向 null,并作为新的 this.head
如果 this.head != null,新增节点指向原来的 this.head,并作为新的 this.head
while 遍历
1 2 3 4 5 6 7 8 9 10 public class SinglyLinkedList { public void loop () { Node curr = this .head; while (curr != null ) { curr = curr.next; } } }
for 遍历
1 2 3 4 5 6 7 8 public class SinglyLinkedList { public void loop () { for (Node curr = this .head; curr != null ; curr = curr.next) { } } }
以上两种遍历都可以把要做的事 以 Consumer 函数的方式传递进来
Consumer 的规则是一个参数 ,无返回值 ,因此像 System.out::println 方法等都是 Consumer
调用 Consumer 时,将当前节点 curr.value 作为参数传递给它
迭代器遍历
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 public class SinglyLinkedList implements Iterable <Integer> { private class NodeIterator implements Iterator <Integer> { Node curr = head; public boolean hasNext () { return curr != null ; } public Integer next () { int value = curr.value; curr = curr.next; return value; } } public Iterator<Integer> iterator () { return new NodeIterator (); } }
hasNext 用来判断是否还有必要调用 next
next 做两件事
NodeIterator 要定义为非 static 内部类 ,是因为它与 SinglyLinkedList 实例相关,是对某个 SinglyLinkedList 实例的迭代
递归遍历
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 public class SinglyLinkedList implements Iterable <Integer> { public void loop () { recursion(this .head); } private void recursion (Node curr) { if (curr == null ) { return ; } recursion(curr.next); } }
尾部添加
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 public class SinglyLinkedList { private Node findLast () { if (this .head == null ) { return null ; } Node curr; for (curr = this .head; curr.next != null ; ) { curr = curr.next; } return curr; } public void addLast (int value) { Node last = findLast(); if (last == null ) { addFirst(value); return ; } last.next = new Node (value, null ); } }
注意,找最后一个节点,终止条件是 curr.next == null
分成两个方法是为了代码清晰,而且 findLast() 之后还能复用
尾部添加多个
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 public class SinglyLinkedList { public void addLast (int first, int ... rest) { Node sublist = new Node (first, null ); Node curr = sublist; for (int value : rest) { curr.next = new Node (value, null ); curr = curr.next; } Node last = findLast(); if (last == null ) { this .head = sublist; return ; } last.next = sublist; } }
根据索引获取
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 public class SinglyLinkedList { private Node findNode (int index) { int i = 0 ; for (Node curr = this .head; curr != null ; curr = curr.next, i++) { if (index == i) { return curr; } } return null ; } private IllegalArgumentException illegalIndex (int index) { return new IllegalArgumentException (String.format("index [%d] 不合法%n" , index)); } public int get (int index) { Node node = findNode(index); if (node != null ) { return node.value; } throw illegalIndex(index); } }
插入
1 2 3 4 5 6 7 8 9 10 11 12 13 14 public class SinglyLinkedList { public void insert (int index, int value) { if (index == 0 ) { addFirst(value); return ; } Node prev = findNode(index - 1 ); if (prev == null ) { throw illegalIndex(index); } prev.next = new Node (value, prev.next); } }
删除
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 public class SinglyLinkedList { public void remove (int index) { if (index == 0 ) { if (this .head != null ) { this .head = this .head.next; return ; } else { throw illegalIndex(index); } } Node prev = findNode(index - 1 ); Node curr; if (prev != null && (curr = prev.next) != null ) { prev.next = curr.next; } else { throw illegalIndex(index); } } }
第一个 if 块对应着 removeFirst 情况
最后一个 if 块对应着至少得两个节点的情况
3) 单向链表(带哨兵) 观察之前单向链表的实现,发现每个方法内几乎都有判断是不是 head 这样的代码,能不能简化呢?
用一个不参与数据存储的特殊 Node 作为哨兵,它一般被称为哨兵或哑元,拥有哨兵节点的链表称为带头链表
1 2 3 4 public class SinglyLinkedListSentinel { private Node head = new Node (Integer.MIN_VALUE, null ); }
加入哨兵节点后,代码会变得比较简单,先看几个工具方法
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 public class SinglyLinkedListSentinel { private Node findNode (int index) { int i = -1 ; for (Node curr = this .head; curr != null ; curr = curr.next, i++) { if (i == index) { return curr; } } return null ; } private Node findLast () { Node curr; for (curr = this .head; curr.next != null ; ) { curr = curr.next; } return curr; } }
findNode 与之前类似,只是 i 初始值设置为 -1 对应哨兵,实际传入的 index 也是 $[-1, \infty)$
findLast 绝不会返回 null 了,就算没有其它节点,也会返回哨兵作为最后一个节点
这样,代码简化为
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 public class SinglyLinkedListSentinel { public void addLast (int value) { Node last = findLast(); last.next = new Node (value, null ); } public void insert (int index, int value) { Node prev = findNode(index - 1 ); if (prev != null ) { prev.next = new Node (value, prev.next); } else { throw illegalIndex(index); } } public void remove (int index) { Node prev = findNode(index - 1 ); Node curr; if (prev != null && (curr = prev.next) != null ) { prev.next = curr.next; } else { throw illegalIndex(index); } } public void addFirst (int value) { this .head.next = new Node (value, this .head.next); } }
对于删除,前面说了【最后一个 if 块对应着至少得两个节点的情况】,现在有了哨兵,就凑足了两个节点
4) 双向链表(带哨兵) 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 public class DoublyLinkedListSentinel implements Iterable <Integer> { private final Node head; private final Node tail; public DoublyLinkedListSentinel () { head = new Node (null , 666 , null ); tail = new Node (null , 888 , null ); head.next = tail; tail.prev = head; } private Node findNode (int index) { int i = -1 ; for (Node p = head; p != tail; p = p.next, i++) { if (i == index) { return p; } } return null ; } public void addFirst (int value) { insert(0 , value); } public void removeFirst () { remove(0 ); } public void addLast (int value) { Node prev = tail.prev; Node added = new Node (prev, value, tail); prev.next = added; tail.prev = added; } public void removeLast () { Node removed = tail.prev; if (removed == head) { throw illegalIndex(0 ); } Node prev = removed.prev; prev.next = tail; tail.prev = prev; } public void insert (int index, int value) { Node prev = findNode(index - 1 ); if (prev == null ) { throw illegalIndex(index); } Node next = prev.next; Node inserted = new Node (prev, value, next); prev.next = inserted; next.prev = inserted; } public void remove (int index) { Node prev = findNode(index - 1 ); if (prev == null ) { throw illegalIndex(index); } Node removed = prev.next; if (removed == tail) { throw illegalIndex(index); } Node next = removed.next; prev.next = next; next.prev = prev; } private IllegalArgumentException illegalIndex (int index) { return new IllegalArgumentException ( String.format("index [%d] 不合法%n" , index)); } @Override public Iterator<Integer> iterator () { return new Iterator <Integer>() { Node p = head.next; @Override public boolean hasNext () { return p != tail; } @Override public Integer next () { int value = p.value; p = p.next; return value; } }; } static class Node { Node prev; int value; Node next; public Node (Node prev, int value, Node next) { this .prev = prev; this .value = value; this .next = next; } } }
5) 环形链表(带哨兵) 双向环形链表带哨兵,这时哨兵既作为头,也作为尾
参考实现
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 public class DoublyLinkedListSentinel implements Iterable <Integer> { @Override public Iterator<Integer> iterator () { return new Iterator <>() { Node p = sentinel.next; @Override public boolean hasNext () { return p != sentinel; } @Override public Integer next () { int value = p.value; p = p.next; return value; } }; } static class Node { Node prev; int value; Node next; public Node (Node prev, int value, Node next) { this .prev = prev; this .value = value; this .next = next; } } private final Node sentinel = new Node (null , -1 , null ); public DoublyLinkedListSentinel () { sentinel.next = sentinel; sentinel.prev = sentinel; } public void addFirst (int value) { Node next = sentinel.next; Node prev = sentinel; Node added = new Node (prev, value, next); prev.next = added; next.prev = added; } public void addLast (int value) { Node prev = sentinel.prev; Node next = sentinel; Node added = new Node (prev, value, next); prev.next = added; next.prev = added; } public void removeFirst () { Node removed = sentinel.next; if (removed == sentinel) { throw new IllegalArgumentException ("非法" ); } Node a = sentinel; Node b = removed.next; a.next = b; b.prev = a; } public void removeLast () { Node removed = sentinel.prev; if (removed == sentinel) { throw new IllegalArgumentException ("非法" ); } Node a = removed.prev; Node b = sentinel; a.next = b; b.prev = a; } public void removeByValue (int value) { Node removed = findNodeByValue(value); if (removed != null ) { Node prev = removed.prev; Node next = removed.next; prev.next = next; next.prev = prev; } } private Node findNodeByValue (int value) { Node p = sentinel.next; while (p != sentinel) { if (p.value == value) { return p; } p = p.next; } return null ; } }
习题