一. 初识算法

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:1]
数据结构是一种存储和组织数据的方式,旨在便于访问和修改

A data structure is a way to store and organize data in order to facilitate access and modifications

接下来我们通过对一个非常著名的二分查找算法的讲解来认识一下算法

1.3 二分查找 [3]

二分查找算法也称折半查找,是一种非常高效的工作于有序数组的查找算法。后续的课程中还会学习更多的查找算法,但在此之前,不妨用它作为入门。

二分查找基础版

需求:在有序数组 数据结构与算法1 - 图1 内,查找值 数据结构与算法1 - 图2

  • 如果找到返回索引
  • 如果找不到返回 数据结构与算法1 - 图3 | 算法描述 | | | —- | —- | | 前提 | 给定一个内含 数据结构与算法1 - 图4 个元素的有序数组 数据结构与算法1 - 图5,满足 数据结构与算法1 - 图6,一个待查值 数据结构与算法1 - 图7 | | 1 | 设置 数据结构与算法1 - 图8数据结构与算法1 - 图9 | | 2 | 如果 数据结构与算法1 - 图10,结束查找,没找到 | | 3 | 设置 数据结构与算法1 - 图11数据结构与算法1 - 图12 为中间索引,数据结构与算法1 - 图13 是向下取整(数据结构与算法1 - 图14 的最小整数) | | 4 | 如果 数据结构与算法1 - 图15 设置 数据结构与算法1 - 图16,跳到第2步 | | 5 | 如果 数据结构与算法1 - 图17 设置 数据结构与算法1 - 图18,跳到第2步 | | 6 | 如果 数据结构与算法1 - 图19,结束查找,找到了 |

java 实现

  1. /**
  2. * 二分查找基础版
  3. *
  4. * i, j, m 指针都可能是查找目标
  5. * 因为 1. i > j 时表示区域内没有要找的了
  6. * 每次改变 i, j 边界时, m 已经比较过不是目标, 因此分别 m+1 m-1
  7. * 向左查找, 比较次数少, 向右查找, 比较次数多
  8. *
  9. * @param a 待查找的升序数组
  10. * @param target 待查找的目标值
  11. * @return 找到则返回索引,找不到返回 -1
  12. */
  13. public static int binarySearchBasic(int[] a, int target) {
  14. int i = 0, j = a.length - 1;// 设置指针和初值
  15. // L 次 元素在最左边 L 次, 元素在最右边 2*L 次
  16. while (i <= j) {// i~j 范围内有东西
  17. int m = (i + j) >>> 1;//取中间下标
  18. if (target < a[m]) {//目标在左边
  19. j = m - 1;//索引缩小到中间值的左侧
  20. } else if (a[m] < target) {//目标在右边
  21. i = m + 1;//索引缩小到中间值的右侧
  22. } else {//找到了
  23. return m;
  24. }
  25. }
  26. return -1;
  27. }
  • 数据结构与算法1 - 图20 对应着搜索区间 数据结构与算法1 - 图21(注意是闭合的区间),数据结构与算法1 - 图22 意味着搜索区间内还有未比较的元素,数据结构与算法1 - 图23 指向的元素也可能是比较的目标
    • 思考:如果不加 数据结构与算法1 - 图24 行不行?
    • 回答:不行,因为这意味着 数据结构与算法1 - 图25 指向的元素会漏过比较
  • 数据结构与算法1 - 图26 对应着中间位置,中间位置左边和右边的元素可能不相等(差一个),不会影响结果
  • 如果某次未找到,那么缩小后的区间内不包含 数据结构与算法1 - 图27

二分查找改变版

另一种写法

  1. public static int binarySearch(int[] a, int target) {
  2. int i = 0, j = a.length;
  3. while (i < j) {
  4. int m = (i + j) >>> 1;
  5. if (target < a[m]) { // 在左边
  6. j = m;
  7. } else if (a[m] < target) { // 在右边
  8. i = m + 1;
  9. } else {
  10. return m;
  11. }
  12. }
  13. return -1;
  14. }
  • 数据结构与算法1 - 图28 对应着搜索区间 数据结构与算法1 - 图29#card=math&code=%5B0%2Ca.length%29&id=WkksU)(注意是左闭右开的区间),数据结构与算法1 - 图30 意味着搜索区间内还有未比较的元素,数据结构与算法1 - 图31 指向的一定不是查找目标
    • 思考:为啥这次不加 数据结构与算法1 - 图32 的条件了?
    • 回答:这回 数据结构与算法1 - 图33 指向的不是查找目标,如果还加 数据结构与算法1 - 图34 条件,就意味着 数据结构与算法1 - 图35 指向的还会再次比较,找不到时,会死循环
  • 如果某次要缩小右边界,那么 数据结构与算法1 - 图36,因为此时的 数据结构与算法1 - 图37 已经不是查找目标了

衡量算法好坏

时间复杂度
下面的查找算法也能得出与之前二分查找一样的结果,那你能说出它差在哪里吗?

  1. public static int search(int[] a, int k) {
  2. for (
  3. int i = 0;
  4. i < a.length;
  5. i++
  6. ) {
  7. if (a[i] == k) {
  8. return i;
  9. }
  10. }
  11. return -1;
  12. }

考虑最坏情况下(没找到)例如 [1,2,3,4] 查找 5

  • int i = 0 只执行一次
  • i < a.length 受数组元素个数 数据结构与算法1 - 图38 的影响,比较 数据结构与算法1 - 图39
  • i++ 受数组元素个数 数据结构与算法1 - 图40 的影响,自增 数据结构与算法1 - 图41
  • a[i] == k 受元素个数 数据结构与算法1 - 图42 的影响,比较 数据结构与算法1 - 图43
  • return -1,执行一次

粗略认为每行代码执行时间是 数据结构与算法1 - 图44,假设 数据结构与算法1 - 图45 那么

  • 总执行时间是 数据结构与算法1 - 图46*t%20%3D%2015t#card=math&code=%281%2B4%2B1%2B4%2B4%2B1%29%2At%20%3D%2015t&id=grNkw)
  • 可以推导出更一般地公式为,数据结构与算法1 - 图47t#card=math&code=T%20%3D%20%283%2An%2B3%29t&id=bgGoi)

如果套用二分查找算法,还是 [1,2,3,4] 查找 5

  1. public static int binarySearch(int[] a, int target) {
  2. int i = 0, j = a.length - 1;
  3. while (i <= j) {
  4. int m = (i + j) >>> 1;
  5. if (target < a[m]) { // 在左边
  6. j = m - 1;
  7. } else if (a[m] < target) { // 在右边
  8. i = m + 1;
  9. } else {
  10. return m;
  11. }
  12. }
  13. return -1;
  14. }
  • int i = 0, j = a.length - 1 各执行 1 次
  • i <= j 比较 数据结构与算法1 - 图48%2B1)#card=math&code=floor%28%5Clog_%7B2%7D%28n%29%2B1%29&id=lz4HD) 再加 1 次
  • (i + j) >>> 1 计算 数据结构与算法1 - 图49%2B1)#card=math&code=floor%28%5Clog_%7B2%7D%28n%29%2B1%29&id=ufz2m) 次
  • 接下来 if() else if() else 会执行 数据结构与算法1 - 图50%2B1)#card=math&code=3%2A%20floor%28%5Clog_%7B2%7D%28n%29%2B1%29&id=CbOMA) 次,分别为
    • if 比较
    • else if 比较
    • else if 比较成立后的赋值语句
  • return -1,执行一次

结果:

  • 总执行时间为 数据结构与算法1 - 图51%20%2B%203%20%2B%203%20%203%20%2B1)t%20%3D%2019t#card=math&code=%282%20%2B%20%281%2B3%29%20%2B%203%20%2B%203%20%2A%203%20%2B1%29%2At%20%3D%2019t&id=gVDA3)
  • 更一般地公式为 数据结构与算法1 - 图52%2B1))*t#card=math&code=%284%20%2B%205%20%2A%20floor%28%5Clog_%7B2%7D%28n%29%2B1%29%29%2At&id=l5Qx1)

注意: 左侧未找到和右侧未找到结果不一样,这里不做分析

两个算法比较,可以看到 数据结构与算法1 - 图53 在较小的时候,二者花费的次数差不多

数据结构与算法1 - 图54

但随着 数据结构与算法1 - 图55 越来越大,比如说 数据结构与算法1 - 图56 时,用二分查找算法(红色)也就是 数据结构与算法1 - 图57,而蓝色算法则需要 数据结构与算法1 - 图58
数据结构与算法1 - 图59

画图采用的是 Desmos | 图形计算器

计算机科学中,时间复杂度是用来衡量:一个算法的执行,随数据规模增大,而增长的时间成本

  • 不依赖于环境因素

如何表示时间复杂度呢?

  • 假设算法要处理的数据规模是 数据结构与算法1 - 图60,代码总的执行行数用函数 数据结构与算法1 - 图61#card=math&code=f%28n%29&id=Lkp0Q) 来表示,例如:
    • 线性查找算法的函数 数据结构与算法1 - 图62%20%3D%203*n%20%2B%203#card=math&code=f%28n%29%20%3D%203%2An%20%2B%203&id=xRe71)
    • 二分查找算法的函数 数据结构与算法1 - 图63%20%3D%20(floor(log_2(n))%20%2B%201)%20*%205%20%2B%204#card=math&code=f%28n%29%20%3D%20%28floor%28log_2%28n%29%29%20%2B%201%29%20%2A%205%20%2B%204&id=KSiHc)
  • 为了对 数据结构与算法1 - 图64#card=math&code=f%28n%29&id=d4a5x) 进行化简,应当抓住主要矛盾,找到一个变化趋势与之相近的表示法

数据结构与算法1 - 图65 表示法[4]
数据结构与算法1 - 图66

其中

  • 数据结构与算法1 - 图67 都为一个常数
  • 数据结构与算法1 - 图68#card=math&code=f%28n%29&id=dcAjy) 是实际执行代码行数与 n 的函数
  • 数据结构与算法1 - 图69#card=math&code=g%28n%29&id=i4KKI) 是经过化简,变化趋势与 数据结构与算法1 - 图70#card=math&code=f%28n%29&id=psIUR) 一致的 n 的函数

渐进上界
渐进上界(asymptotic upper bound):从某个常数 数据结构与算法1 - 图71开始,数据结构与算法1 - 图72#card=math&code=c%2Ag%28n%29&id=cpp1Y) 总是位于 数据结构与算法1 - 图73#card=math&code=f%28n%29&id=PmcGp) 上方,那么记作 数据结构与算法1 - 图74)#card=math&code=O%28g%28n%29%29&id=xzpUq)

  • 代表算法执行的最差情况

例1

  • 数据结构与算法1 - 图75%20%3D%203*n%2B3#card=math&code=f%28n%29%20%3D%203%2An%2B3&id=qa4Ly)
  • 数据结构与算法1 - 图76%20%3D%20n#card=math&code=g%28n%29%20%3D%20n&id=yUK7u)
  • 数据结构与算法1 - 图77,在数据结构与算法1 - 图78 之后,数据结构与算法1 - 图79#card=math&code=g%28n%29&id=nf0Eq) 可以作为 数据结构与算法1 - 图80#card=math&code=f%28n%29&id=OMSUF) 的渐进上界,因此表示法写作 数据结构与算法1 - 图81#card=math&code=O%28n%29&id=AVf7S)

例2

  • 数据结构与算法1 - 图82%20%3D%205*floor(log_2(n))%20%2B%209#card=math&code=f%28n%29%20%3D%205%2Afloor%28log_2%28n%29%29%20%2B%209&id=hRpP6)
  • 数据结构与算法1 - 图83%20%3D%20log_2(n)#card=math&code=g%28n%29%20%3D%20log_2%28n%29&id=MjjGT)
  • 数据结构与算法1 - 图84)#card=math&code=O%28log_2%28n%29%29&id=hAsYL)

已知 数据结构与算法1 - 图85#card=math&code=f%28n%29&id=i4cHH) 来说,求 数据结构与算法1 - 图86#card=math&code=g%28n%29&id=gHmyJ)

  • 表达式中相乘的常量,可以省略,如
    • 数据结构与算法1 - 图87%20%3D%20100*n%5E2#card=math&code=f%28n%29%20%3D%20100%2An%5E2&id=xwgGT) 中的 数据结构与算法1 - 图88
  • 多项式中数量规模更小(低次项)的表达式,如
    • 数据结构与算法1 - 图89%3Dn%5E2%2Bn#card=math&code=f%28n%29%3Dn%5E2%2Bn&id=L6uQm) 中的 数据结构与算法1 - 图90
    • 数据结构与算法1 - 图91%20%3D%20n%5E3%20%2B%20n%5E2#card=math&code=f%28n%29%20%3D%20n%5E3%20%2B%20n%5E2&id=CBzq0) 中的 数据结构与算法1 - 图92
  • 不同底数的对数,渐进上界可以用一个对数函数 数据结构与算法1 - 图93 表示
  • 类似的,对数的常数次幂可省略
    • 如:数据结构与算法1 - 图96%20%3D%20c%20*%20log(n)#card=math&code=log%28n%5Ec%29%20%3D%20c%20%2A%20log%28n%29&id=d8fKE)

常见大 数据结构与算法1 - 图97 表示法
数据结构与算法1 - 图98

按时间复杂度从低到高

  • 黑色横线 数据结构与算法1 - 图99#card=math&code=O%281%29&id=RyxLo),常量时间,意味着算法时间并不随数据规模而变化
  • 绿色 数据结构与算法1 - 图100)#card=math&code=O%28log%28n%29%29&id=Hd7od),对数时间
  • 蓝色 数据结构与算法1 - 图101#card=math&code=O%28n%29&id=jNday),线性时间,算法时间与数据规模成正比
  • 橙色 数据结构与算法1 - 图102)#card=math&code=O%28n%2Alog%28n%29%29&id=gKf37),拟线性时间
  • 红色 数据结构与算法1 - 图103#card=math&code=O%28n%5E2%29&id=dcig4) 平方时间
  • 黑色朝上 数据结构与算法1 - 图104#card=math&code=O%282%5En%29&id=U0jUF) 指数时间
  • 没画出来的 数据结构与算法1 - 图105#card=math&code=O%28n%21%29&id=DZ7Bl)

渐进下界
渐进下界(asymptotic lower bound):从某个常数 数据结构与算法1 - 图106开始,数据结构与算法1 - 图107#card=math&code=c%2Ag%28n%29&id=ogOvL) 总是位于 数据结构与算法1 - 图108#card=math&code=f%28n%29&id=wDa1L) 下方,那么记作 数据结构与算法1 - 图109)#card=math&code=%5COmega%28g%28n%29%29&id=mDWj8)

渐进紧界
渐进紧界(asymptotic tight bounds):从某个常数 数据结构与算法1 - 图110开始,数据结构与算法1 - 图111#card=math&code=f%28n%29&id=zj4yo) 总是在 数据结构与算法1 - 图112#card=math&code=c_1%2Ag%28n%29&id=tURhv) 和 数据结构与算法1 - 图113#card=math&code=c_2%2Ag%28n%29&id=wxndy) 之间,那么记作 数据结构与算法1 - 图114)#card=math&code=%5CTheta%28g%28n%29%29&id=cboiv)

空间复杂度
与时间复杂度类似,一般也使用大 数据结构与算法1 - 图115 表示法来衡量:一个算法执行随数据规模增大,而增长的额外空间成本

  1. public static int binarySearchBasic(int[] a, int target) {
  2. int i = 0, j = a.length - 1; // 设置指针和初值
  3. while (i <= j) { // i~j 范围内有东西
  4. int m = (i + j) >>> 1;
  5. if(target < a[m]) { // 目标在左边
  6. j = m - 1;
  7. } else if (a[m] < target) { // 目标在右边
  8. i = m + 1;
  9. } else { // 找到了
  10. return m;
  11. }
  12. }
  13. return -1;
  14. }

二分查找性能
下面分析二分查找算法的性能

时间复杂度

  • 最坏情况:数据结构与算法1 - 图116#card=math&code=O%28%5Clog%20n%29&id=KFbjd)
  • 最好情况:如果待查找元素恰好在数组中央,只需要循环一次 数据结构与算法1 - 图117#card=math&code=O%281%29&id=ueOsj)

空间复杂度

  • 需要常数个指针 数据结构与算法1 - 图118,因此额外占用的空间是 数据结构与算法1 - 图119#card=math&code=O%281%29&id=rJury)

二分查找平衡版

  1. public static int binarySearchBalance(int[] a, int target) {
  2. int i = 0, j = a.length;
  3. while (1 < j - i) {
  4. int m = (i + j) >>> 1;
  5. if (target < a[m]) {
  6. j = m;
  7. } else {
  8. i = m;
  9. }
  10. }
  11. return (a[i] == target) ? i : -1;
  12. }

思想:

  1. 左闭右开的区间,数据结构与算法1 - 图120 指向的可能是目标,而 数据结构与算法1 - 图121 指向的不是目标
  2. 不奢望循环内通过 数据结构与算法1 - 图122 找出目标, 缩小区间直至剩 1 个, 剩下的这个可能就是要找的(通过 数据结构与算法1 - 图123
    • 数据结构与算法1 - 图124 的含义是,在范围内待比较的元素个数 > 1
  3. 改变 数据结构与算法1 - 图125 边界时,它指向的可能是目标,因此不能 数据结构与算法1 - 图126
  4. 循环内的平均比较次数减少了
  5. 时间复杂度 数据结构与算法1 - 图127)#card=math&code=%5CTheta%28log%28n%29%29&id=COhxP)

二分查找 Java 版

  1. private static int binarySearch0(long[] a, int fromIndex, int toIndex,
  2. long key) {
  3. int low = fromIndex;
  4. int high = toIndex - 1;
  5. while (low <= high) {
  6. int mid = (low + high) >>> 1;
  7. long midVal = a[mid];
  8. if (midVal < key)
  9. low = mid + 1;
  10. else if (midVal > key)
  11. high = mid - 1;
  12. else
  13. return mid; // key found
  14. }
  15. return -(low + 1); // key not found.
  16. }
  • 例如 数据结构与算法1 - 图128 要插入 数据结构与算法1 - 图129 那么就是找到一个位置,这个位置左侧元素都比它小
    • 等循环结束,若没找到,low 左侧元素肯定都比 target 小,因此 low 即插入点
  • 插入点取负是为了与找到情况区分
  • -1 是为了把索引 0 位置的插入点与找到的情况进行区分

Leftmost 与 Rightmost

有时我们希望返回的是最左侧的重复元素,如果用 Basic 二分查找

  • 对于数组 数据结构与算法1 - 图130,查找元素4,结果是索引3
  • 对于数组 数据结构与算法1 - 图131,查找元素4,结果也是索引3,并不是最左侧的元素
    1. public static int binarySearchLeftmost1(int[] a, int target) {
    2. int i = 0, j = a.length - 1;
    3. int candidate = -1;
    4. while (i <= j) {
    5. int m = (i + j) >>> 1;
    6. if (target < a[m]) {
    7. j = m - 1;
    8. } else if (a[m] < target) {
    9. i = m + 1;
    10. } else {
    11. candidate = m; // 记录候选位置
    12. j = m - 1; // 继续向左
    13. }
    14. }
    15. return candidate;
    16. }

如果希望返回的是最右侧元素

  1. public static int binarySearchRightmost1(int[] a, int target) {
  2. int i = 0, j = a.length - 1;
  3. int candidate = -1;
  4. while (i <= j) {
  5. int m = (i + j) >>> 1;
  6. if (target < a[m]) {
  7. j = m - 1;
  8. } else if (a[m] < target) {
  9. i = m + 1;
  10. } else {
  11. candidate = m; // 记录候选位置
  12. i = m + 1; // 继续向右
  13. }
  14. }
  15. return candidate;
  16. }

应用
对于 Leftmost 与 Rightmost,可以返回一个比 -1 更有用的值
Leftmost 改为

  1. public static int binarySearchLeftmost(int[] a, int target) {
  2. int i = 0, j = a.length - 1;
  3. while (i <= j) {
  4. int m = (i + j) >>> 1;
  5. if (target <= a[m]) {
  6. j = m - 1;
  7. } else {
  8. i = m + 1;
  9. }
  10. }
  11. return i;
  12. }
  • leftmost 返回值的另一层含义:数据结构与算法1 - 图132 的元素个数
  • 小于等于中间值,都要向左找

Rightmost 改为

  1. public static int binarySearchRightmost(int[] a, int target) {
  2. int i = 0, j = a.length - 1;
  3. while (i <= j) {
  4. int m = (i + j) >>> 1;
  5. if (target < a[m]) {
  6. j = m - 1;
  7. } else {
  8. i = m + 1;
  9. }
  10. }
  11. return i - 1;
  12. }
  • 大于等于中间值,都要向右找

几个名词
数据结构与算法1 - 图133

范围查询

  • 查询 数据结构与算法1 - 图134数据结构与算法1 - 图135%20-%201#card=math&code=0%20..%20leftmost%284%29%20-%201&id=YmRaZ)
  • 查询 数据结构与算法1 - 图136数据结构与算法1 - 图137#card=math&code=0%20..%20rightmost%284%29&id=BpWz8)
  • 查询 数据结构与算法1 - 图138,$rightmost(4) + 1 .. \infty $
  • 查询 数据结构与算法1 - 图139数据结构与算法1 - 图140%20..%20%5Cinfty#card=math&code=leftmost%284%29%20..%20%5Cinfty&id=Z7XOp)
  • 查询 数据结构与算法1 - 图141数据结构与算法1 - 图142%20..%20rightmost(7)#card=math&code=leftmost%284%29%20..%20rightmost%287%29&id=yHjYK)
  • 查询 数据结构与算法1 - 图143数据结构与算法1 - 图144%2B1%20..%20leftmost(7)-1#card=math&code=rightmost%284%29%2B1%20..%20leftmost%287%29-1&id=azCG3)

求排名数据结构与算法1 - 图145%20%2B%201#card=math&code=leftmost%28target%29%20%2B%201&id=e9kAL)

  • 数据结构与算法1 - 图146 可以不存在,如:数据结构与算法1 - 图147%2B1%20%3D%206#card=math&code=leftmost%285%29%2B1%20%3D%206&id=M8IKv)
  • 数据结构与算法1 - 图148 也可以存在,如:数据结构与算法1 - 图149%2B1%20%3D%203#card=math&code=leftmost%284%29%2B1%20%3D%203&id=I6KJ2)

求前任(predecessor)数据结构与算法1 - 图150%20-%201#card=math&code=leftmost%28target%29%20-%201&id=tsGDW)

  • 数据结构与算法1 - 图151%20-%201%20%3D%201#card=math&code=leftmost%283%29%20-%201%20%3D%201&id=Qv9As),前任 数据结构与算法1 - 图152
  • 数据结构与算法1 - 图153%20-%201%20%3D%201#card=math&code=leftmost%284%29%20-%201%20%3D%201&id=TgZys),前任 数据结构与算法1 - 图154

求后任(successor)数据结构与算法1 - 图155%2B1#card=math&code=rightmost%28target%29%2B1&id=SKLTY)

  • 数据结构与算法1 - 图156%20%2B%201%20%3D%205#card=math&code=rightmost%285%29%20%2B%201%20%3D%205&id=Ki5km),后任 数据结构与算法1 - 图157
  • 数据结构与算法1 - 图158%20%2B%201%20%3D%205#card=math&code=rightmost%284%29%20%2B%201%20%3D%205&id=ifal8),后任 数据结构与算法1 - 图159

求最近邻居

  • 前任和后任距离更近者

二. 基础数据结构

2.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}

知道了数组的数据起始地址 数据结构与算法1 - 图160,就可以由公式 数据结构与算法1 - 图161 计算出索引 数据结构与算法1 - 图162 元素的地址

  • 数据结构与算法1 - 图163 即索引,在 Java、C 等语言都是从 0 开始
  • 数据结构与算法1 - 图164 是每个元素占用字节,例如 数据结构与算法1 - 图165数据结构与算法1 - 图166数据结构与算法1 - 图167数据结构与算法1 - 图168

小测试

  1. byte[] array = {1,2,3,4,5}

已知 array 的数据的起始地址是 0x7138f94c8,那么元素 3 的地址是什么?

答:0x7138f94c8 + 2 * 1 = 0x7138f94ca

空间占用
Java 中数组结构为

  • 8 字节 markword
  • 4 字节 class 指针(压缩 class 指针的情况)
  • 4 字节 数组大小(决定了数组最大容量是 数据结构与算法1 - 图169
  • 数组元素 + 对齐字节(java 中所有对象大小都是 8 字节的整数倍[5],不足的要用对齐字节补足)

例如

  1. int[] array = {1, 2, 3, 4, 5};

的大小为 40 个字节,组成如下

  1. 8 + 4 + 4 + 5*4 + 4(alignment)

随机访问性能
即根据索引查找元素,时间复杂度是 数据结构与算法1 - 图170#card=math&code=O%281%29&id=BTUCK)

动态数组

java 版本

  1. public class DynamicArray implements Iterable<Integer> {
  2. private int size = 0; // 逻辑大小
  3. private int capacity = 8; // 容量
  4. private int[] array = {};
  5. /**
  6. * 向最后位置 [size] 添加元素
  7. *
  8. * @param element 待添加元素
  9. */
  10. public void addLast(int element) {
  11. add(size, element);
  12. }
  13. /**
  14. * 向 [0 .. size] 位置添加元素
  15. *
  16. * @param index 索引位置
  17. * @param element 待添加元素
  18. */
  19. public void add(int index, int element) {
  20. checkAndGrow();
  21. // 添加逻辑
  22. if (index >= 0 && index < size) {
  23. // 向后挪动, 空出待插入位置
  24. System.arraycopy(array, index,
  25. array, index + 1, size - index);
  26. }
  27. array[index] = element;
  28. size++;
  29. }
  30. private void checkAndGrow() {
  31. // 容量检查
  32. if (size == 0) {
  33. array = new int[capacity];
  34. } else if (size == capacity) {
  35. // 进行扩容, 1.5 1.618 2
  36. capacity += capacity >> 1;
  37. int[] newArray = new int[capacity];
  38. System.arraycopy(array, 0,
  39. newArray, 0, size);
  40. array = newArray;
  41. }
  42. }
  43. /**
  44. * 从 [0 .. size) 范围删除元素
  45. *
  46. * @param index 索引位置
  47. * @return 被删除元素
  48. */
  49. public int remove(int index) { // [0..size)
  50. int removed = array[index];
  51. if (index < size - 1) {
  52. // 向前挪动
  53. System.arraycopy(array, index + 1,
  54. array, index, size - index - 1);
  55. }
  56. size--;
  57. return removed;
  58. }
  59. /**
  60. * 查询元素
  61. *
  62. * @param index 索引位置, 在 [0..size) 区间内
  63. * @return 该索引位置的元素
  64. */
  65. public int get(int index) {
  66. return array[index];
  67. }
  68. /**
  69. * 遍历方法1
  70. *
  71. * @param consumer 遍历要执行的操作, 入参: 每个元素
  72. */
  73. public void foreach(Consumer<Integer> consumer) {
  74. for (int i = 0; i < size; i++) {
  75. // 提供 array[i]
  76. // 返回 void
  77. consumer.accept(array[i]);
  78. }
  79. }
  80. /**
  81. * 遍历方法2 - 迭代器遍历
  82. */
  83. @Override
  84. public Iterator<Integer> iterator() {
  85. return new Iterator<Integer>() {
  86. int i = 0;
  87. @Override
  88. public boolean hasNext() { // 有没有下一个元素
  89. return i < size;
  90. }
  91. @Override
  92. public Integer next() { // 返回当前元素,并移动到下一个元素
  93. return array[i++];
  94. }
  95. };
  96. }
  97. /**
  98. * 遍历方法3 - stream 遍历
  99. *
  100. * @return stream 流
  101. */
  102. public IntStream stream() {
  103. return IntStream.of(Arrays.copyOfRange(array, 0, size));
  104. }
  105. }
  • 这些方法实现,都简化了 index 的有效性判断,假设输入的 index 都是合法的

插入或删除性能
头部位置,时间复杂度是 数据结构与算法1 - 图171#card=math&code=O%28n%29&id=RloX6)
中间位置,时间复杂度是 数据结构与算法1 - 图172#card=math&code=O%28n%29&id=PqZ7g)
尾部位置,时间复杂度是 数据结构与算法1 - 图173#card=math&code=O%281%29&id=x6CMv)(均摊来说)

二维数组

  1. int[][] array = {
  2. {11, 12, 13, 14, 15},
  3. {21, 22, 23, 24, 25},
  4. {31, 32, 33, 34, 35},
  5. };

内存图如下
数据结构与算法1 - 图174

  • 二维数组占 32 个字节,其中 array[0],array[1],array[2] 三个元素分别保存了指向三个一维数组的引用
  • 三个一维数组各占 40 个字节
  • 它们在内层布局上是连续

更一般的,对一个二维数组 数据结构与算法1 - 图175

  • 数据结构与算法1 - 图176 是外层数组的长度,可以看作 row 行
  • 数据结构与算法1 - 图177 是内层数组的长度,可以看作 column 列
  • 当访问 数据结构与算法1 - 图178数据结构与算法1 - 图179时,就相当于
    • 先找到第 数据结构与算法1 - 图180 个内层数组(行)
    • 再找到此内层数组中第 数据结构与算法1 - 图181 个元素(列)

小测试
Java 环境下(不考虑类指针和引用压缩,此为默认情况),有下面的二维数组

  1. byte[][] array = {
  2. {11, 12, 13, 14, 15},
  3. {21, 22, 23, 24, 25},
  4. {31, 32, 33, 34, 35},
  5. };

已知 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

局部性原理

这里只讨论空间局部性

  • cpu 读取内存(速度慢)数据后,会将其放入高速缓存(速度快)当中,如果后来的计算再用到此数据,在缓存中能读到的话,就不必读内存了
  • 缓存的最小存储单位是缓存行(cache line),一般是 64 bytes,一次读的数据少了不划算啊,因此最少读 64 bytes 填满一个缓存行,因此读入某个数据时也会读取其临近的数据,这就是所谓空间局部性

对效率的影响
比较下面 ij 和 ji 两个方法的执行效率

  1. int rows = 1000000;
  2. int columns = 14;
  3. int[][] a = new int[rows][columns];
  4. StopWatch sw = new StopWatch();
  5. sw.start("ij");
  6. ij(a, rows, columns);
  7. sw.stop();
  8. sw.start("ji");
  9. ji(a, rows, columns);
  10. sw.stop();
  11. System.out.println(sw.prettyPrint());

ij 方法

  1. public static void ij(int[][] a, int rows, int columns) {
  2. long sum = 0L;
  3. for (int i = 0; i < rows; i++) {
  4. for (int j = 0; j < columns; j++) {
  5. sum += a[i][j];
  6. }
  7. }
  8. System.out.println(sum);
  9. }

ji 方法

  1. public static void ji(int[][] a, int rows, int columns) {
  2. long sum = 0L;
  3. for (int j = 0; j < columns; j++) {
  4. for (int i = 0; i < rows; i++) {
  5. sum += a[i][j];
  6. }
  7. }
  8. System.out.println(sum);
  9. }

执行结果

  1. 0
  2. 0
  3. StopWatch '': running time = 96283300 ns
  4. ---------------------------------------------
  5. ns % Task name
  6. ---------------------------------------------
  7. 016196200 017% ij
  8. 080087100 083% ji

可以看到 ij 的效率比 ji 快很多,为什么呢?

  • 缓存是有限的,当新数据来了后,一些旧的缓存行数据就会被覆盖
  • 如果不能充分利用缓存的数据,就会造成效率低下

以 ji 执行为例,第一次内循环要读入 数据结构与算法1 - 图182 这条数据,由于局部性原理,读入 数据结构与算法1 - 图183 的同时也读入了 数据结构与算法1 - 图184,如图所示
数据结构与算法1 - 图185

但很遗憾,第二次内循环要的是 数据结构与算法1 - 图186 这条数据,缓存中没有,于是再读入了下图的数据
数据结构与算法1 - 图187

这显然是一种浪费,因为 数据结构与算法1 - 图188 包括 数据结构与算法1 - 图189 这些数据虽然读入了缓存,却没有及时用上,而缓存的大小是有限的,等执行到第九次内循环时
数据结构与算法1 - 图190

缓存的第一行数据已经被新的数据 数据结构与算法1 - 图191 覆盖掉了,以后如果再想读,比如 数据结构与算法1 - 图192,又得到内存去读了
同理可以分析 ij 函数则能充分利用局部性原理加载到的缓存数据

举一反三

  1. I/O 读写时同样可以体现局部性原理
  2. 数组可以充分利用局部性原理,那么链表呢?

    答:链表不行,因为链表的元素并非相邻存储

越界检查

java 中对数组元素的读写都有越界检查,类似于下面的代码

  1. bool is_within_bounds(int index) const
  2. {
  3. return 0 <= index && index < length();
  4. }
  • 代码位置:openjdk\src\hotspot\share\oops\arrayOop.hpp

只不过此检查代码,不需要由程序员自己来调用,JVM 会帮我们调用

2.2 链表

概述

定义
在计算机科学中,链表是数据元素的线性集合,其每个元素都指向下一个元素,元素存储上并不连续

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.

可以分类为[6]

  • 单向链表,每个元素只知道其下一个元素是谁

数据结构与算法1 - 图193

  • 双向链表,每个元素知道其上一个元素和下一个元素

数据结构与算法1 - 图194

  • 循环链表,通常的链表尾节点 tail 指向的都是 null,而循环链表的 tail 指向的是头节点 head

数据结构与算法1 - 图195

链表内还有一种特殊的节点称为哨兵(Sentinel)节点,也叫做哑元( Dummy)节点,它不存储数据,通常用作头尾,用来简化边界判断,如下图所示
数据结构与算法1 - 图196

随机访问性能
根据 index 查找,时间复杂度 数据结构与算法1 - 图197#card=math&code=O%28n%29&id=JQAOU)

插入或删除性能

  • 起始位置:数据结构与算法1 - 图198#card=math&code=O%281%29&id=lxeQQ)
  • 结束位置:如果已知 tail 尾节点是 数据结构与算法1 - 图199#card=math&code=O%281%29&id=CRUDo),不知道 tail 尾节点是 数据结构与算法1 - 图200#card=math&code=O%28n%29&id=Q4ib9)
  • 中间位置:根据 index 查找时间 + 数据结构与算法1 - 图201#card=math&code=O%281%29&id=KaWAS)

单向链表

根据单向链表的定义,首先定义一个存储 value 和 next 指针的类 Node,和一个描述头部节点的引用

  1. public class SinglyLinkedList {
  2. private Node head; // 头部节点
  3. private static class Node { // 节点类
  4. int value;
  5. Node next;
  6. public Node(int value, Node next) {
  7. this.value = value;
  8. this.next = next;
  9. }
  10. }
  11. }
  • Node 定义为内部类,是为了对外隐藏实现细节,没必要让类的使用者关心 Node 结构
  • 定义为 static 内部类,是因为 Node 不需要与 SinglyLinkedList 实例相关,多个 SinglyLinkedList实例能共用 Node 类定义

头部添加

  1. public class SinglyLinkedList {
  2. // ...
  3. public void addFirst(int value) {
  4. this.head = new Node(value, this.head);
  5. }
  6. }
  • 如果 this.head == null,新增节点指向 null,并作为新的 this.head
  • 如果 this.head != null,新增节点指向原来的 this.head,并作为新的 this.head
    • 注意赋值操作执行顺序是从右到左

while 遍历

  1. public class SinglyLinkedList {
  2. // ...
  3. public void loop() {
  4. Node curr = this.head;
  5. while (curr != null) {
  6. // 做一些事
  7. curr = curr.next;
  8. }
  9. }
  10. }

for 遍历

  1. public class SinglyLinkedList {
  2. // ...
  3. public void loop() {
  4. for (Node curr = this.head; curr != null; curr = curr.next) {
  5. // 做一些事
  6. }
  7. }
  8. }
  • 以上两种遍历都可以把要做的事以 Consumer 函数的方式传递进来
    • Consumer 的规则是一个参数无返回值,因此像 System.out::println 方法等都是 Consumer
    • 调用 Consumer 时,将当前节点 curr.value 作为参数传递给它

迭代器遍历

  1. public class SinglyLinkedList implements Iterable<Integer> {
  2. // ...
  3. private class NodeIterator implements Iterator<Integer> {
  4. Node curr = head;
  5. public boolean hasNext() {
  6. return curr != null;
  7. }
  8. public Integer next() {
  9. int value = curr.value;
  10. curr = curr.next;
  11. return value;
  12. }
  13. }
  14. public Iterator<Integer> iterator() {
  15. return new NodeIterator();
  16. }
  17. }
  • hasNext 用来判断是否还有必要调用 next
  • next 做两件事
    • 返回当前节点的 value
    • 指向下一个节点
  • NodeIterator 要定义为非 static 内部类,是因为它与 SinglyLinkedList 实例相关,是对某个 SinglyLinkedList 实例的迭代

递归遍历

  1. public class SinglyLinkedList implements Iterable<Integer> {
  2. // ...
  3. public void loop() {
  4. recursion(this.head);
  5. }
  6. private void recursion(Node curr) {
  7. if (curr == null) {
  8. return;
  9. }
  10. // 前面做些事
  11. recursion(curr.next);
  12. // 后面做些事
  13. }
  14. }

尾部添加

  1. public class SinglyLinkedList {
  2. // ...
  3. private Node findLast() {
  4. if (this.head == null) {
  5. return null;
  6. }
  7. Node curr;
  8. for (curr = this.head; curr.next != null; ) {
  9. curr = curr.next;
  10. }
  11. return curr;
  12. }
  13. public void addLast(int value) {
  14. Node last = findLast();
  15. if (last == null) {
  16. addFirst(value);
  17. return;
  18. }
  19. last.next = new Node(value, null);
  20. }
  21. }
  • 注意,找最后一个节点,终止条件是 curr.next == null
  • 分成两个方法是为了代码清晰,而且 findLast() 之后还能复用

尾部添加多个

  1. public class SinglyLinkedList {
  2. // ...
  3. public void addLast(int first, int... rest) {
  4. Node sublist = new Node(first, null);
  5. Node curr = sublist;
  6. for (int value : rest) {
  7. curr.next = new Node(value, null);
  8. curr = curr.next;
  9. }
  10. Node last = findLast();
  11. if (last == null) {
  12. this.head = sublist;
  13. return;
  14. }
  15. last.next = sublist;
  16. }
  17. }
  • 先串成一串 sublist
  • 再作为一个整体添加

根据索引获取

  1. public class SinglyLinkedList {
  2. // ...
  3. private Node findNode(int index) {
  4. int i = 0;
  5. for (Node curr = this.head; curr != null; curr = curr.next, i++) {
  6. if (index == i) {
  7. return curr;
  8. }
  9. }
  10. return null;
  11. }
  12. private IllegalArgumentException illegalIndex(int index) {
  13. return new IllegalArgumentException(String.format("index [%d] 不合法%n", index));
  14. }
  15. public int get(int index) {
  16. Node node = findNode(index);
  17. if (node != null) {
  18. return node.value;
  19. }
  20. throw illegalIndex(index);
  21. }
  22. }
  • 同样,分方法可以实现复用

插入

  1. public class SinglyLinkedList {
  2. // ...
  3. public void insert(int index, int value) {
  4. if (index == 0) {
  5. addFirst(value);
  6. return;
  7. }
  8. Node prev = findNode(index - 1); // 找到上一个节点
  9. if (prev == null) { // 找不到
  10. throw illegalIndex(index);
  11. }
  12. prev.next = new Node(value, prev.next);
  13. }
  14. }
  • 插入包括下面的删除,都必须找到上一个节点

删除

  1. public class SinglyLinkedList {
  2. // ...
  3. public void remove(int index) {
  4. if (index == 0) {
  5. if (this.head != null) {
  6. this.head = this.head.next;
  7. return;
  8. } else {
  9. throw illegalIndex(index);
  10. }
  11. }
  12. Node prev = findNode(index - 1);
  13. Node curr;
  14. if (prev != null && (curr = prev.next) != null) {
  15. prev.next = curr.next;
  16. } else {
  17. throw illegalIndex(index);
  18. }
  19. }
  20. }
  • 第一个 if 块对应着 removeFirst 情况
  • 最后一个 if 块对应着至少得两个节点的情况
    • 不仅仅判断上一个节点非空,还要保证当前节点非空

单向链表(带哨兵)

观察之前单向链表的实现,发现每个方法内几乎都有判断是不是 head 这样的代码,能不能简化呢?

用一个不参与数据存储的特殊 Node 作为哨兵,它一般被称为哨兵或哑元,拥有哨兵节点的链表称为带头链表

  1. public class SinglyLinkedListSentinel {
  2. // ...
  3. private Node head = new Node(Integer.MIN_VALUE, null);
  4. }
  • 具体存什么值无所谓,因为不会用到它的值

加入哨兵节点后,代码会变得比较简单,先看几个工具方法

  1. public class SinglyLinkedListSentinel {
  2. // ...
  3. // 根据索引获取节点
  4. private Node findNode(int index) {
  5. int i = -1;
  6. for (Node curr = this.head; curr != null; curr = curr.next, i++) {
  7. if (i == index) {
  8. return curr;
  9. }
  10. }
  11. return null;
  12. }
  13. // 获取最后一个节点
  14. private Node findLast() {
  15. Node curr;
  16. for (curr = this.head; curr.next != null; ) {
  17. curr = curr.next;
  18. }
  19. return curr;
  20. }
  21. }
  • findNode 与之前类似,只是 i 初始值设置为 -1 对应哨兵,实际传入的 index 也是 数据结构与算法1 - 图202#card=math&code=%5B-1%2C%20%5Cinfty%29&id=TVnM0)
  • findLast 绝不会返回 null 了,就算没有其它节点,也会返回哨兵作为最后一个节点

这样,代码简化为

  1. public class SinglyLinkedListSentinel {
  2. // ...
  3. public void addLast(int value) {
  4. Node last = findLast();
  5. /*
  6. 改动前
  7. if (last == null) {
  8. this.head = new Node(value, null);
  9. return;
  10. }
  11. */
  12. last.next = new Node(value, null);
  13. }
  14. public void insert(int index, int value) {
  15. /*
  16. 改动前
  17. if (index == 0) {
  18. this.head = new Node(value, this.head);
  19. return;
  20. }
  21. */
  22. // index 传入 0 时,返回的是哨兵
  23. Node prev = findNode(index - 1);
  24. if (prev != null) {
  25. prev.next = new Node(value, prev.next);
  26. } else {
  27. throw illegalIndex(index);
  28. }
  29. }
  30. public void remove(int index) {
  31. /*
  32. 改动前
  33. if (index == 0) {
  34. if (this.head != null) {
  35. this.head = this.head.next;
  36. return;
  37. } else {
  38. throw illegalIndex(index);
  39. }
  40. }
  41. */
  42. // index 传入 0 时,返回的是哨兵
  43. Node prev = findNode(index - 1);
  44. Node curr;
  45. if (prev != null && (curr = prev.next) != null) {
  46. prev.next = curr.next;
  47. } else {
  48. throw illegalIndex(index);
  49. }
  50. }
  51. public void addFirst(int value) {
  52. /*
  53. 改动前
  54. this.head = new Node(value, this.head);
  55. */
  56. this.head.next = new Node(value, this.head.next);
  57. // 也可以视为 insert 的特例, 即 insert(0, value);
  58. }
  59. }
  • 对于删除,前面说了【最后一个 if 块对应着至少得两个节点的情况】,现在有了哨兵,就凑足了两个节点

双向链表(带哨兵)

  1. public class DoublyLinkedListSentinel implements Iterable<Integer> {
  2. private final Node head;
  3. private final Node tail;
  4. public DoublyLinkedListSentinel() {
  5. head = new Node(null, 666, null);
  6. tail = new Node(null, 888, null);
  7. head.next = tail;
  8. tail.prev = head;
  9. }
  10. private Node findNode(int index) {
  11. int i = -1;
  12. for (Node p = head; p != tail; p = p.next, i++) {
  13. if (i == index) {
  14. return p;
  15. }
  16. }
  17. return null;
  18. }
  19. public void addFirst(int value) {
  20. insert(0, value);
  21. }
  22. public void removeFirst() {
  23. remove(0);
  24. }
  25. public void addLast(int value) {
  26. Node prev = tail.prev;
  27. Node added = new Node(prev, value, tail);
  28. prev.next = added;
  29. tail.prev = added;
  30. }
  31. public void removeLast() {
  32. Node removed = tail.prev;
  33. if (removed == head) {
  34. throw illegalIndex(0);
  35. }
  36. Node prev = removed.prev;
  37. prev.next = tail;
  38. tail.prev = prev;
  39. }
  40. public void insert(int index, int value) {
  41. Node prev = findNode(index - 1);
  42. if (prev == null) {
  43. throw illegalIndex(index);
  44. }
  45. Node next = prev.next;
  46. Node inserted = new Node(prev, value, next);
  47. prev.next = inserted;
  48. next.prev = inserted;
  49. }
  50. public void remove(int index) {
  51. Node prev = findNode(index - 1);
  52. if (prev == null) {
  53. throw illegalIndex(index);
  54. }
  55. Node removed = prev.next;
  56. if (removed == tail) {
  57. throw illegalIndex(index);
  58. }
  59. Node next = removed.next;
  60. prev.next = next;
  61. next.prev = prev;
  62. }
  63. private IllegalArgumentException illegalIndex(int index) {
  64. return new IllegalArgumentException(
  65. String.format("index [%d] 不合法%n", index));
  66. }
  67. @Override
  68. public Iterator<Integer> iterator() {
  69. return new Iterator<Integer>() {
  70. Node p = head.next;
  71. @Override
  72. public boolean hasNext() {
  73. return p != tail;
  74. }
  75. @Override
  76. public Integer next() {
  77. int value = p.value;
  78. p = p.next;
  79. return value;
  80. }
  81. };
  82. }
  83. static class Node {
  84. Node prev;
  85. int value;
  86. Node next;
  87. public Node(Node prev, int value, Node next) {
  88. this.prev = prev;
  89. this.value = value;
  90. this.next = next;
  91. }
  92. }
  93. }

环形链表(带哨兵)

双向环形链表带哨兵,这时哨兵既作为头,也作为尾

数据结构与算法1 - 图203

数据结构与算法1 - 图204

数据结构与算法1 - 图205

数据结构与算法1 - 图206

参考实现

  1. public class DoublyLinkedListSentinel implements Iterable<Integer> {
  2. @Override
  3. public Iterator<Integer> iterator() {
  4. return new Iterator<>() {
  5. Node p = sentinel.next;
  6. @Override
  7. public boolean hasNext() {
  8. return p != sentinel;
  9. }
  10. @Override
  11. public Integer next() {
  12. int value = p.value;
  13. p = p.next;
  14. return value;
  15. }
  16. };
  17. }
  18. static class Node {
  19. Node prev;
  20. int value;
  21. Node next;
  22. public Node(Node prev, int value, Node next) {
  23. this.prev = prev;
  24. this.value = value;
  25. this.next = next;
  26. }
  27. }
  28. private final Node sentinel = new Node(null, -1, null); // 哨兵
  29. public DoublyLinkedListSentinel() {
  30. sentinel.next = sentinel;
  31. sentinel.prev = sentinel;
  32. }
  33. /**
  34. * 添加到第一个
  35. * @param value 待添加值
  36. */
  37. public void addFirst(int value) {
  38. Node next = sentinel.next;
  39. Node prev = sentinel;
  40. Node added = new Node(prev, value, next);
  41. prev.next = added;
  42. next.prev = added;
  43. }
  44. /**
  45. * 添加到最后一个
  46. * @param value 待添加值
  47. */
  48. public void addLast(int value) {
  49. Node prev = sentinel.prev;
  50. Node next = sentinel;
  51. Node added = new Node(prev, value, next);
  52. prev.next = added;
  53. next.prev = added;
  54. }
  55. /**
  56. * 删除第一个
  57. */
  58. public void removeFirst() {
  59. Node removed = sentinel.next;
  60. if (removed == sentinel) {
  61. throw new IllegalArgumentException("非法");
  62. }
  63. Node a = sentinel;
  64. Node b = removed.next;
  65. a.next = b;
  66. b.prev = a;
  67. }
  68. /**
  69. * 删除最后一个
  70. */
  71. public void removeLast() {
  72. Node removed = sentinel.prev;
  73. if (removed == sentinel) {
  74. throw new IllegalArgumentException("非法");
  75. }
  76. Node a = removed.prev;
  77. Node b = sentinel;
  78. a.next = b;
  79. b.prev = a;
  80. }
  81. /**
  82. * 根据值删除节点
  83. * <p>假定 value 在链表中作为 key, 有唯一性</p>
  84. * @param value 待删除值
  85. */
  86. public void removeByValue(int value) {
  87. Node removed = findNodeByValue(value);
  88. if (removed != null) {
  89. Node prev = removed.prev;
  90. Node next = removed.next;
  91. prev.next = next;
  92. next.prev = prev;
  93. }
  94. }
  95. private Node findNodeByValue(int value) {
  96. Node p = sentinel.next;
  97. while (p != sentinel) {
  98. if (p.value == value) {
  99. return p;
  100. }
  101. p = p.next;
  102. }
  103. return null;
  104. }
  105. }

2.3 递归

概述

定义

计算机科学中,递归是一种解决计算问题的方法,其中解决方案取决于同一类问题的更小子集

In computer science, recursion is a method of solving a computational problem where the solution depends on solutions to smaller instances of the same problem.

比如单链表递归遍历的例子:

  1. void f(Node node) {
  2. if(node == null) {
  3. return;
  4. }
  5. println("before:" + node.value)
  6. f(node.next);
  7. println("after:" + node.value)
  8. }

说明:

  1. 自己调用自己,如果说每个函数对应着一种解决方案,自己调用自己意味着解决方案是一样的(有规律的)
  2. 每次调用,函数处理的数据会较上次缩减(子集),而且最后会缩减至无需继续递归
  3. 内层函数调用(子集处理)完成,外层函数才能算调用完成

原理

假设链表中有 3 个节点,value 分别为 1,2,3,以上代码的执行流程就类似于下面的伪码

  1. // 1 -> 2 -> 3 -> null f(1)
  2. void f(Node node = 1) {
  3. println("before:" + node.value) // 1
  4. void f(Node node = 2) {
  5. println("before:" + node.value) // 2
  6. void f(Node node = 3) {
  7. println("before:" + node.value) // 3
  8. void f(Node node = null) {
  9. if(node == null) {
  10. return;
  11. }
  12. }
  13. println("after:" + node.value) // 3
  14. }
  15. println("after:" + node.value) // 2
  16. }
  17. println("after:" + node.value) // 1
  18. }

思路

  1. 确定能否使用递归求解
  2. 推导出递推关系,即父问题与子问题的关系,以及递归的结束条件

例如之前遍历链表的递推关系为

数据结构与算法1 - 图207%20%3D%20%0A%5Cbegin%7Bcases%7D%0A%E5%81%9C%E6%AD%A2%26%20n%20%3D%20null%20%5C%5C%0Af(n.next)%20%26%20n%20%5Cneq%20null%0A%5Cend%7Bcases%7D%0A#card=math&code=f%28n%29%20%3D%20%0A%5Cbegin%7Bcases%7D%0A%E5%81%9C%E6%AD%A2%26%20n%20%3D%20null%20%5C%5C%0Af%28n.next%29%20%26%20n%20%5Cneq%20null%0A%5Cend%7Bcases%7D%0A&id=N1o1I)

  • 深入到最里层叫做
  • 从最里层出来叫做
  • 的过程中,外层函数内的局部变量(以及方法参数)并未消失,的时候还可以用到

单路递归 Single Recursion

E01. 阶乘

用递归方法求阶乘

  • 阶乘的定义 数据结构与算法1 - 图208%E2%8B%85(n-1)%E2%8B%85n#card=math&code=n%21%3D%201%E2%8B%852%E2%8B%853%E2%8B%AF%28n-2%29%E2%8B%85%28n-1%29%E2%8B%85n&id=hjSJM),其中 数据结构与算法1 - 图209 为自然数,当然 数据结构与算法1 - 图210
  • 递推关系

数据结构与算法1 - 图211%20%3D%20%0A%5Cbegin%7Bcases%7D%0A1%20%26%20n%20%3D%201%5C%5C%0An%20*%20f(n-1)%20%26%20n%20%3E%201%0A%5Cend%7Bcases%7D%0A#card=math&code=f%28n%29%20%3D%20%0A%5Cbegin%7Bcases%7D%0A1%20%26%20n%20%3D%201%5C%5C%0An%20%2A%20f%28n-1%29%20%26%20n%20%3E%201%0A%5Cend%7Bcases%7D%0A&id=UMEVU)

代码

  1. private static int f(int n) {
  2. if (n == 1) {
  3. return 1;
  4. }
  5. return n * f(n - 1);
  6. }

拆解伪码如下,假设 n 初始值为 3

  1. f(int n = 3) { // 解决不了,递
  2. return 3 * f(int n = 2) { // 解决不了,继续递
  3. return 2 * f(int n = 1) {
  4. if (n == 1) { // 可以解决, 开始归
  5. return 1;
  6. }
  7. }
  8. }
  9. }

E02. 反向打印字符串

用递归反向打印字符串,n 为字符在整个字符串 str 中的索引位置

  • :n 从 0 开始,每次 n + 1,一直递到 n == str.length() - 1
  • :从 n == str.length() 开始归,从归打印,自然是逆序的

递推关系

数据结构与算法1 - 图212%20%3D%20%0A%5Cbegin%7Bcases%7D%0A%E5%81%9C%E6%AD%A2%20%26%20n%20%3D%20str.length()%20%5C%5C%0Af(n%2B1)%20%26%200%20%5Cleq%20n%20%5Cleq%20str.length()%20-%201%0A%5Cend%7Bcases%7D%0A#card=math&code=f%28n%29%20%3D%20%0A%5Cbegin%7Bcases%7D%0A%E5%81%9C%E6%AD%A2%20%26%20n%20%3D%20str.length%28%29%20%5C%5C%0Af%28n%2B1%29%20%26%200%20%5Cleq%20n%20%5Cleq%20str.length%28%29%20-%201%0A%5Cend%7Bcases%7D%0A&id=dGI5b)

代码为

  1. public static void reversePrint(String str, int index) {
  2. if (index == str.length()) {
  3. return;
  4. }
  5. reversePrint(str, index + 1);
  6. System.out.println(str.charAt(index));
  7. }

拆解伪码如下,假设字符串为 “abc”

  1. void reversePrint(String str, int index = 0) {
  2. void reversePrint(String str, int index = 1) {
  3. void reversePrint(String str, int index = 2) {
  4. void reversePrint(String str, int index = 3) {
  5. if (index == str.length()) {
  6. return; // 开始归
  7. }
  8. }
  9. System.out.println(str.charAt(index)); // 打印 c
  10. }
  11. System.out.println(str.charAt(index)); // 打印 b
  12. }
  13. System.out.println(str.charAt(index)); // 打印 a
  14. }

多路递归 Multi Recursion

E01. 斐波那契数列

  • 之前的例子是每个递归函数只包含一个自身的调用,这称之为 single recursion
  • 如果每个递归函数例包含多个自身调用,称之为 multi recursion

递推关系

数据结构与算法1 - 图213%20%3D%20%0A%5Cbegin%7Bcases%7D%0A0%20%26%20n%3D0%20%5C%5C%0A1%20%26%20n%3D1%20%5C%5C%0Af(n-1)%20%2B%20f(n-2)%20%26%20n%3E1%0A%5Cend%7Bcases%7D%0A#card=math&code=f%28n%29%20%3D%20%0A%5Cbegin%7Bcases%7D%0A0%20%26%20n%3D0%20%5C%5C%0A1%20%26%20n%3D1%20%5C%5C%0Af%28n-1%29%20%2B%20f%28n-2%29%20%26%20n%3E1%0A%5Cend%7Bcases%7D%0A&id=WQzKK)

下面的表格列出了数列的前几项

_F_0 _F_1 _F_2 _F_3 _F_4 _F_5 _F_6 _F_7 _F_8 _F_9 _F_10 _F_11 _F_12 _F_13
0 1 1 2 3 5 8 13 21 34 55 89 144 233

实现

  1. public static int f(int n) {
  2. if (n == 0) {
  3. return 0;
  4. }
  5. if (n == 1) {
  6. return 1;
  7. }
  8. return f(n - 1) + f(n - 2);
  9. }

执行流程

数据结构与算法1 - 图214

  • 绿色代表正在执行(对应递),灰色代表执行结束(对应归)
  • 递不到头,不能归,对应着深度优先搜索

时间复杂度

  • 递归的次数也符合斐波那契规律,数据结构与算法1 - 图215-1#card=math&code=2%20%2A%20f%28n%2B1%29-1&id=d1OMw)
  • 时间复杂度推导过程
    • 斐波那契通项公式 数据结构与算法1 - 图216%20%3D%20%5Cfrac%7B1%7D%7B%5Csqrt%7B5%7D%7D*(%7B%5Cfrac%7B1%2B%5Csqrt%7B5%7D%7D%7B2%7D%7D%5En%20-%20%7B%5Cfrac%7B1-%5Csqrt%7B5%7D%7D%7B2%7D%7D%5En)#card=math&code=f%28n%29%20%3D%20%5Cfrac%7B1%7D%7B%5Csqrt%7B5%7D%7D%2A%28%7B%5Cfrac%7B1%2B%5Csqrt%7B5%7D%7D%7B2%7D%7D%5En%20-%20%7B%5Cfrac%7B1-%5Csqrt%7B5%7D%7D%7B2%7D%7D%5En%29&id=T8qZP)
    • 简化为:数据结构与算法1 - 图217%20%3D%20%5Cfrac%7B1%7D%7B2.236%7D*(%7B1.618%7D%5En%20-%20%7B(-0.618)%7D%5En)#card=math&code=f%28n%29%20%3D%20%5Cfrac%7B1%7D%7B2.236%7D%2A%28%7B1.618%7D%5En%20-%20%7B%28-0.618%29%7D%5En%29&id=HkhVV)
    • 带入递归次数公式 数据结构与算法1 - 图218%7D%5E%7Bn%2B1%7D)-1#card=math&code=2%2A%5Cfrac%7B1%7D%7B2.236%7D%2A%28%7B1.618%7D%5E%7Bn%2B1%7D%20-%20%7B%28-0.618%29%7D%5E%7Bn%2B1%7D%29-1&id=iJTPs)
    • 时间复杂度为 数据结构与算法1 - 图219#card=math&code=%5CTheta%281.618%5En%29&id=DsIrb)
  1. 更多 Fibonacci 参考[7][8][9]
  2. 以上时间复杂度分析,未考虑大数相加的因素

变体1 - 兔子问题[7:1]

数据结构与算法1 - 图220

  • 第一个月,有一对未成熟的兔子(黑色,注意图中个头较小)
  • 第二个月,它们成熟
  • 第三个月,它们能产下一对新的小兔子(蓝色)
  • 所有兔子遵循相同规律,求第 数据结构与算法1 - 图221 个月的兔子数

分析

兔子问题如何与斐波那契联系起来呢?设第 n 个月兔子数为 数据结构与算法1 - 图222#card=math&code=f%28n%29&id=DNgIR)

  • 数据结构与算法1 - 图223#card=math&code=f%28n%29&id=bDzV4) = 上个月兔子数 + 新生的小兔子数
  • 而【新生的小兔子数】实际就是【上个月成熟的兔子数】
  • 因为需要一个月兔子就成熟,所以【上个月成熟的兔子数】也就是【上上个月的兔子数】
  • 上个月兔子数,即 数据结构与算法1 - 图224#card=math&code=f%28n-1%29&id=UzvyG)
  • 上上个月的兔子数,即 数据结构与算法1 - 图225#card=math&code=f%28n-2%29&id=HyBMj)

因此本质还是斐波那契数列,只是从其第一项开始

变体2 - 青蛙爬楼梯

  • 楼梯有 数据结构与算法1 - 图226
  • 青蛙要爬到楼顶,可以一次跳一阶,也可以一次跳两阶
  • 只能向上跳,问有多少种跳法

分析

n 跳法 规律
1 (1) 暂时看不出
2 (1,1) (2) 暂时看不出
3 (1,1,1) (1,2) (2,1) 暂时看不出
4 (1,1,1,1) (1,2,1) (2,1,1)
(1,1,2) (2,2)
最后一跳,跳一个台阶的,基于f(3)
最后一跳,跳两个台阶的,基于f(2)
5

递归优化-记忆法

上述代码存在很多重复的计算,例如求 数据结构与算法1 - 图227#card=math&code=f%285%29&id=vfU4G) 递归分解过程

数据结构与算法1 - 图228

可以看到(颜色相同的是重复的):

  • 数据结构与算法1 - 图229#card=math&code=f%283%29&id=d4slf) 重复了 2 次
  • 数据结构与算法1 - 图230#card=math&code=f%282%29&id=Aaoyq) 重复了 3 次
  • 数据结构与算法1 - 图231#card=math&code=f%281%29&id=CfuLB) 重复了 5 次
  • 数据结构与算法1 - 图232#card=math&code=f%280%29&id=K6iVT) 重复了 3 次

随着 数据结构与算法1 - 图233 的增大,重复次数非常可观,如何优化呢?

Memoization 记忆法(也称备忘录)是一种优化技术,通过存储函数调用结果(通常比较昂贵),当再次出现相同的输入(子问题)时,就能实现加速效果,改进后的代码

  1. public static void main(String[] args) {
  2. int n = 13;
  3. int[] cache = new int[n + 1];
  4. Arrays.fill(cache, -1);
  5. cache[0] = 0;
  6. cache[1] = 1;
  7. System.out.println(f(cache, n));
  8. }
  9. public static int f(int[] cache, int n) {
  10. if (cache[n] != -1) {
  11. return cache[n];
  12. }
  13. cache[n] = f(cache, n - 1) + f(cache, n - 2);
  14. return cache[n];
  15. }

优化后的图示,只要结果被缓存,就不会执行其子问题

数据结构与算法1 - 图234

  • 改进后的时间复杂度为 数据结构与算法1 - 图235#card=math&code=O%28n%29&id=kEtT6)
  • 请自行验证改进后的效果
  • 请自行分析改进后的空间复杂度

注意

  1. 记忆法是动态规划的一种情况,强调的是自顶向下的解决
  2. 记忆法的本质是空间换时间

递归优化-尾递归

爆栈

用递归做 数据结构与算法1 - 图236%20%2B%20(n-2)%20…%20%2B%201#card=math&code=n%20%2B%20%28n-1%29%20%2B%20%28n-2%29%20…%20%2B%201&id=adTLJ)

  1. public static long sum(long n) {
  2. if (n == 1) {
  3. return 1;
  4. }
  5. return n + sum(n - 1);
  6. }

在我的机器上 数据结构与算法1 - 图237 时,爆栈了

  1. Exception in thread "main" java.lang.StackOverflowError
  2. at Test.sum(Test.java:10)
  3. at Test.sum(Test.java:10)
  4. at Test.sum(Test.java:10)
  5. at Test.sum(Test.java:10)
  6. at Test.sum(Test.java:10)
  7. ...

为什么呢?

  • 每次方法调用是需要消耗一定的栈内存的,这些内存用来存储方法参数、方法内局部变量、返回地址等等
  • 方法调用占用的内存需要等到方法结束时才会释放
  • 而递归调用我们之前讲过,不到最深不会回头,最内层方法没完成之前,外层方法都结束不了
    • 例如,数据结构与算法1 - 图238#card=math&code=sum%283%29&id=tCBKV) 这个方法内有个需要执行 数据结构与算法1 - 图239#card=math&code=3%20%2B%20sum%282%29&id=djgdn),数据结构与算法1 - 图240#card=math&code=sum%282%29&id=lYB47) 没返回前,加号前面的 数据结构与算法1 - 图241 不能释放
    • 看下面伪码
  1. long sum(long n = 3) {
  2. return 3 + long sum(long n = 2) {
  3. return 2 + long sum(long n = 1) {
  4. return 1;
  5. }
  6. }
  7. }

尾调用

如果函数的最后一步是调用一个函数,那么称为尾调用,例如

  1. function a() {
  2. return b()
  3. }

下面三段代码不能叫做尾调用

  1. function a() {
  2. const c = b()
  3. return c
  4. }
  • 因为最后一步并非调用函数
  1. function a() {
  2. return b() + 1
  3. }
  • 最后一步执行的是加法
  1. function a(x) {
  2. return b() + x
  3. }
  • 最后一步执行的是加法

一些语言[10]的编译器能够对尾调用做优化,例如

  1. function a() {
  2. // 做前面的事
  3. return b()
  4. }
  5. function b() {
  6. // 做前面的事
  7. return c()
  8. }
  9. function c() {
  10. return 1000
  11. }
  12. a()

没优化之前的伪码

  1. function a() {
  2. return function b() {
  3. return function c() {
  4. return 1000
  5. }
  6. }
  7. }

优化后伪码如下

  1. a()
  2. b()
  3. c()

为何尾递归才能优化?

调用 a 时

  • a 返回时发现:没什么可留给 b 的,将来返回的结果 b 提供就可以了,用不着我 a 了,我的内存就可以释放

调用 b 时

  • b 返回时发现:没什么可留给 c 的,将来返回的结果 c 提供就可以了,用不着我 b 了,我的内存就可以释放

如果调用 a 时

  • 不是尾调用,例如 return b() + 1,那么 a 就不能提前结束,因为它还得利用 b 的结果做加法

尾递归

尾递归是尾调用的一种特例,也就是最后一步执行的是同一个函数

尾递归避免爆栈

安装 Scala

数据结构与算法1 - 图242

Scala 入门

  1. object Main {
  2. def main(args: Array[String]): Unit = {
  3. println("Hello Scala")
  4. }
  5. }
  • Scala 是 java 的近亲,java 中的类都可以拿来重用
  • 类型是放在变量后面的
  • Unit 表示无返回值,类似于 void
  • 不需要以分号作为结尾,当然加上也对

还是先写一个会爆栈的函数

  1. def sum(n: Long): Long = {
  2. if (n == 1) {
  3. return 1
  4. }
  5. return n + sum(n - 1)
  6. }
  • Scala 最后一行代码若作为返回值,可以省略 return

不出所料,在 数据结构与算法1 - 图243 时,还是出了异常

  1. println(sum(11000))
  2. Exception in thread "main" java.lang.StackOverflowError
  3. at Main$.sum(Main.scala:25)
  4. at Main$.sum(Main.scala:25)
  5. at Main$.sum(Main.scala:25)
  6. at Main$.sum(Main.scala:25)
  7. ...

这是因为以上代码,还不是尾调用,要想成为尾调用,那么:

  1. 最后一行代码,必须是一次函数调用
  2. 内层函数必须摆脱与外层函数的关系,内层函数执行后不依赖于外层的变量或常量
  1. def sum(n: Long): Long = {
  2. if (n == 1) {
  3. return 1
  4. }
  5. return n + sum(n - 1) // 依赖于外层函数的 n 变量
  6. }

如何让它执行后就摆脱对 n 的依赖呢?

  • 不能等递归回来再做加法,那样就必须保留外层的 n
  • 把 n 当做内层函数的一个参数传进去,这时 n 就属于内层函数了
  • 传参时就完成累加, 不必等回来时累加
  1. sum(n - 1, n + 累加器)

改写后代码如下

  1. @tailrec
  2. def sum(n: Long, accumulator: Long): Long = {
  3. if (n == 1) {
  4. return 1 + accumulator
  5. }
  6. return sum(n - 1, n + accumulator)
  7. }
  • accumulator 作为累加器
  • @tailrec 注解是 scala 提供的,用来检查方法是否符合尾递归
  • 这回 sum(10000000, 0) 也没有问题,打印 50000005000000

执行流程如下,以伪码表示 数据结构与算法1 - 图244#card=math&code=sum%284%2C%200%29&id=Oi5ig)

  1. // 首次调用
  2. def sum(n = 4, accumulator = 0): Long = {
  3. return sum(4 - 1, 4 + accumulator)
  4. }
  5. // 接下来调用内层 sum, 传参时就完成了累加, 不必等回来时累加,当内层 sum 调用后,外层 sum 空间没必要保留
  6. def sum(n = 3, accumulator = 4): Long = {
  7. return sum(3 - 1, 3 + accumulator)
  8. }
  9. // 继续调用内层 sum
  10. def sum(n = 2, accumulator = 7): Long = {
  11. return sum(2 - 1, 2 + accumulator)
  12. }
  13. // 继续调用内层 sum, 这是最后的 sum 调用完就返回最后结果 10, 前面所有其它 sum 的空间早已释放
  14. def sum(n = 1, accumulator = 9): Long = {
  15. if (1 == 1) {
  16. return 1 + accumulator
  17. }
  18. }

本质上,尾递归优化是将函数的递归调用,变成了函数的循环调用

改循环避免爆栈

  1. public static void main(String[] args) {
  2. long n = 100000000;
  3. long sum = 0;
  4. for (long i = n; i >= 1; i--) {
  5. sum += i;
  6. }
  7. System.out.println(sum);
  8. }

递归时间复杂度-Master theorem[11]

若有递归式

数据结构与算法1 - 图245%20%3D%20aT(%5Cfrac%7Bn%7D%7Bb%7D)%20%2B%20f(n)%0A#card=math&code=T%28n%29%20%3D%20aT%28%5Cfrac%7Bn%7D%7Bb%7D%29%20%2B%20f%28n%29%0A&id=ihtlI)

其中

  • 数据结构与算法1 - 图246#card=math&code=T%28n%29&id=zp6q0) 是问题的运行时间,数据结构与算法1 - 图247 是数据规模
  • 数据结构与算法1 - 图248 是子问题个数
  • 数据结构与算法1 - 图249#card=math&code=T%28%5Cfrac%7Bn%7D%7Bb%7D%29&id=P6vGr) 是子问题运行时间,每个子问题被拆成原问题数据规模的 数据结构与算法1 - 图250
  • 数据结构与算法1 - 图251#card=math&code=f%28n%29&id=L1NAV) 是除递归外执行的计算

数据结构与算法1 - 图252,即 数据结构与算法1 - 图253

那么

数据结构与算法1 - 图254%20%3D%20%0A%5Cbegin%7Bcases%7D%0A%5CTheta(n%5Ex)%20%26%20f(n)%20%3D%20O(n%5Ec)%20%E5%B9%B6%E4%B8%94%20c%20%5Clt%20x%5C%5C%0A%5CTheta(n%5Ex%5Clog%7Bn%7D)%20%26%20f(n)%20%3D%20%5CTheta(n%5Ex)%5C%5C%0A%5CTheta(n%5Ec)%20%26%20f(n)%20%3D%20%5COmega(n%5Ec)%20%E5%B9%B6%E4%B8%94%20c%20%5Cgt%20x%0A%5Cend%7Bcases%7D%0A#card=math&code=T%28n%29%20%3D%20%0A%5Cbegin%7Bcases%7D%0A%5CTheta%28n%5Ex%29%20%26%20f%28n%29%20%3D%20O%28n%5Ec%29%20%E5%B9%B6%E4%B8%94%20c%20%5Clt%20x%5C%5C%0A%5CTheta%28n%5Ex%5Clog%7Bn%7D%29%20%26%20f%28n%29%20%3D%20%5CTheta%28n%5Ex%29%5C%5C%0A%5CTheta%28n%5Ec%29%20%26%20f%28n%29%20%3D%20%5COmega%28n%5Ec%29%20%E5%B9%B6%E4%B8%94%20c%20%5Cgt%20x%0A%5Cend%7Bcases%7D%0A&id=mhhJD)

例1

数据结构与算法1 - 图255%20%3D%202T(%5Cfrac%7Bn%7D%7B2%7D)%20%2B%20n%5E4#card=math&code=T%28n%29%20%3D%202T%28%5Cfrac%7Bn%7D%7B2%7D%29%20%2B%20n%5E4&id=FSHQi)

  • 此时 数据结构与算法1 - 图256,由后者决定整个时间复杂度 数据结构与算法1 - 图257#card=math&code=%5CTheta%28n%5E4%29&id=Gqeg1)
  • 如果觉得对数不好算,可以换为求【数据结构与算法1 - 图258 的几次方能等于 数据结构与算法1 - 图259

例2

数据结构与算法1 - 图260%20%3D%20T(%5Cfrac%7B7n%7D%7B10%7D)%20%2B%20n#card=math&code=T%28n%29%20%3D%20T%28%5Cfrac%7B7n%7D%7B10%7D%29%20%2B%20n&id=jV9Eh)

  • 数据结构与算法1 - 图261
  • 此时 数据结构与算法1 - 图262,由后者决定整个时间复杂度 数据结构与算法1 - 图263#card=math&code=%5CTheta%28n%29&id=pOZhv)

例3

数据结构与算法1 - 图264%20%3D%2016T(%5Cfrac%7Bn%7D%7B4%7D)%20%2B%20n%5E2#card=math&code=T%28n%29%20%3D%2016T%28%5Cfrac%7Bn%7D%7B4%7D%29%20%2B%20n%5E2&id=U2yoQ)

  • 数据结构与算法1 - 图265
  • 此时 数据结构与算法1 - 图266,时间复杂度 数据结构与算法1 - 图267#card=math&code=%5CTheta%28n%5E2%20%5Clog%7Bn%7D%29&id=VMzmc)

例4

数据结构与算法1 - 图268%3D7T(%5Cfrac%7Bn%7D%7B3%7D)%20%2B%20n%5E2#card=math&code=T%28n%29%3D7T%28%5Cfrac%7Bn%7D%7B3%7D%29%20%2B%20n%5E2&id=RCezl)

  • 数据结构与算法1 - 图269
  • 此时 数据结构与算法1 - 图270,由后者决定整个时间复杂度 数据结构与算法1 - 图271#card=math&code=%5CTheta%28n%5E2%29&id=xmw8G)

例5

数据结构与算法1 - 图272%20%3D%207T(%5Cfrac%7Bn%7D%7B2%7D)%20%2B%20n%5E2#card=math&code=T%28n%29%20%3D%207T%28%5Cfrac%7Bn%7D%7B2%7D%29%20%2B%20n%5E2&id=jXt5k)

  • 数据结构与算法1 - 图273
  • 此时 数据结构与算法1 - 图274,由前者决定整个时间复杂度 数据结构与算法1 - 图275#card=math&code=%5CTheta%28n%5E%7B%5Clog_2%7B7%7D%7D%29&id=jXbVu)

例6

数据结构与算法1 - 图276%20%3D%202T(%5Cfrac%7Bn%7D%7B4%7D)%20%2B%20%5Csqrt%7Bn%7D#card=math&code=T%28n%29%20%3D%202T%28%5Cfrac%7Bn%7D%7B4%7D%29%20%2B%20%5Csqrt%7Bn%7D&id=KfkaP)

  • 数据结构与算法1 - 图277
  • 此时 数据结构与算法1 - 图278,时间复杂度 数据结构与算法1 - 图279#card=math&code=%5CTheta%28%5Csqrt%7Bn%7D%5C%20%5Clog%7Bn%7D%29&id=mU9A8)

例7. 二分查找递归

  1. int f(int[] a, int target, int i, int j) {
  2. if (i > j) {
  3. return -1;
  4. }
  5. int m = (i + j) >>> 1;
  6. if (target < a[m]) {
  7. return f(a, target, i, m - 1);
  8. } else if (a[m] < target) {
  9. return f(a, target, m + 1, j);
  10. } else {
  11. return m;
  12. }
  13. }
  • 子问题个数 数据结构与算法1 - 图280
  • 子问题数据规模缩小倍数 数据结构与算法1 - 图281
  • 除递归外执行的计算是常数级 数据结构与算法1 - 图282

数据结构与算法1 - 图283%20%3D%20T(%5Cfrac%7Bn%7D%7B2%7D)%20%2B%20n%5E0#card=math&code=T%28n%29%20%3D%20T%28%5Cfrac%7Bn%7D%7B2%7D%29%20%2B%20n%5E0&id=R94N5)

  • 此时 数据结构与算法1 - 图284,时间复杂度 数据结构与算法1 - 图285#card=math&code=%5CTheta%28%5Clog%7Bn%7D%29&id=qU3Gh)

例8. 归并排序递归

  1. void split(B[], i, j, A[])
  2. {
  3. if (j - i <= 1)
  4. return;
  5. m = (i + j) / 2;
  6. // 递归
  7. split(A, i, m, B);
  8. split(A, m, j, B);
  9. // 合并
  10. merge(B, i, m, j, A);
  11. }
  • 子问题个数 数据结构与算法1 - 图286
  • 子问题数据规模缩小倍数 数据结构与算法1 - 图287
  • 除递归外,主要时间花在合并上,它可以用 数据结构与算法1 - 图288%20%3D%20n#card=math&code=f%28n%29%20%3D%20n&id=ejxTT) 表示

数据结构与算法1 - 图289%20%3D%202T(%5Cfrac%7Bn%7D%7B2%7D)%20%2B%20n#card=math&code=T%28n%29%20%3D%202T%28%5Cfrac%7Bn%7D%7B2%7D%29%20%2B%20n&id=uHsQi)

  • 此时 数据结构与算法1 - 图290,时间复杂度 数据结构与算法1 - 图291#card=math&code=%5CTheta%28n%5Clog%7Bn%7D%29&id=Fvym4)

例9. 快速排序递归

  1. algorithm quicksort(A, lo, hi) is
  2. if lo >= hi || lo < 0 then
  3. return
  4. // 分区
  5. p := partition(A, lo, hi)
  6. // 递归
  7. quicksort(A, lo, p - 1)
  8. quicksort(A, p + 1, hi)
  • 子问题个数 数据结构与算法1 - 图292
  • 子问题数据规模缩小倍数
    • 如果分区分的好,数据结构与算法1 - 图293
    • 如果分区没分好,例如分区1 的数据是 0,分区 2 的数据是 数据结构与算法1 - 图294
  • 除递归外,主要时间花在分区上,它可以用 数据结构与算法1 - 图295%20%3D%20n#card=math&code=f%28n%29%20%3D%20n&id=nfXz5) 表示

情况1 - 分区分的好

数据结构与算法1 - 图296%20%3D%202T(%5Cfrac%7Bn%7D%7B2%7D)%20%2B%20n#card=math&code=T%28n%29%20%3D%202T%28%5Cfrac%7Bn%7D%7B2%7D%29%20%2B%20n&id=oneKV)

  • 此时 数据结构与算法1 - 图297,时间复杂度 数据结构与算法1 - 图298#card=math&code=%5CTheta%28n%5Clog%7Bn%7D%29&id=ooIfR)

情况2 - 分区没分好

数据结构与算法1 - 图299%20%3D%20T(n-1)%20%2B%20T(1)%20%2B%20n#card=math&code=T%28n%29%20%3D%20T%28n-1%29%20%2B%20T%281%29%20%2B%20n&id=cErI4)

  • 此时不能用主定理求解

递归时间复杂度-展开求解

像下面的递归式,都不能用主定理求解

例1 - 递归求和

  1. long sum(long n) {
  2. if (n == 1) {
  3. return 1;
  4. }
  5. return n + sum(n - 1);
  6. }

数据结构与算法1 - 图300%20%3D%20T(n-1)%20%2B%20c#card=math&code=T%28n%29%20%3D%20T%28n-1%29%20%2B%20c&id=cmVHC),数据结构与算法1 - 图301%20%3D%20c#card=math&code=T%281%29%20%3D%20c&id=TBarX)

下面为展开过程

数据结构与算法1 - 图302%20%3D%20T(n-2)%20%2B%20c%20%2B%20c#card=math&code=T%28n%29%20%3D%20T%28n-2%29%20%2B%20c%20%2B%20c&id=u94CR)

数据结构与算法1 - 图303%20%3D%20T(n-3)%20%2B%20c%20%2B%20c%20%2B%20c#card=math&code=T%28n%29%20%3D%20T%28n-3%29%20%2B%20c%20%2B%20c%20%2B%20c&id=zyiyF)

数据结构与算法1 - 图304%20%3D%20T(n-(n-1))%20%2B%20(n-1)c#card=math&code=T%28n%29%20%3D%20T%28n-%28n-1%29%29%20%2B%20%28n-1%29c&id=wEq6g)

  • 其中 数据结构与算法1 - 图305)#card=math&code=T%28n-%28n-1%29%29&id=CF34M) 即 数据结构与算法1 - 图306#card=math&code=T%281%29&id=ZaDJO)
  • 带入求得 数据结构与算法1 - 图307%20%3D%20c%20%2B%20(n-1)c%20%3D%20nc#card=math&code=T%28n%29%20%3D%20c%20%2B%20%28n-1%29c%20%3D%20nc&id=JPsUw)

时间复杂度为 数据结构与算法1 - 图308#card=math&code=O%28n%29&id=BrzB6)

例2 - 递归冒泡排序

  1. void bubble(int[] a, int high) {
  2. if(0 == high) {
  3. return;
  4. }
  5. for (int i = 0; i < high; i++) {
  6. if (a[i] > a[i + 1]) {
  7. swap(a, i, i + 1);
  8. }
  9. }
  10. bubble(a, high - 1);
  11. }

数据结构与算法1 - 图309%20%3D%20T(n-1)%20%2B%20n#card=math&code=T%28n%29%20%3D%20T%28n-1%29%20%2B%20n&id=TagsF),数据结构与算法1 - 图310%20%3D%20c#card=math&code=T%281%29%20%3D%20c&id=yRjTt)

下面为展开过程

数据结构与算法1 - 图311%20%3D%20T(n-2)%20%2B%20(n-1)%20%2B%20n#card=math&code=T%28n%29%20%3D%20T%28n-2%29%20%2B%20%28n-1%29%20%2B%20n&id=Ur4Vn)

数据结构与算法1 - 图312%20%3D%20T(n-3)%20%2B%20(n-2)%20%2B%20(n-1)%20%2B%20n#card=math&code=T%28n%29%20%3D%20T%28n-3%29%20%2B%20%28n-2%29%20%2B%20%28n-1%29%20%2B%20n&id=jDCTB)

数据结构与算法1 - 图313%20%3D%20T(1)%20%2B%202%20%2B%20…%20%2B%20n%20%3D%20T(1)%20%2B%20(n-1)%5Cfrac%7B2%2Bn%7D%7B2%7D%20%3D%20c%20%2B%20%5Cfrac%7Bn%5E2%7D%7B2%7D%20%2B%20%5Cfrac%7Bn%7D%7B2%7D%20-1#card=math&code=T%28n%29%20%3D%20T%281%29%20%2B%202%20%2B%20…%20%2B%20n%20%3D%20T%281%29%20%2B%20%28n-1%29%5Cfrac%7B2%2Bn%7D%7B2%7D%20%3D%20c%20%2B%20%5Cfrac%7Bn%5E2%7D%7B2%7D%20%2B%20%5Cfrac%7Bn%7D%7B2%7D%20-1&id=vmbJL)

时间复杂度 数据结构与算法1 - 图314#card=math&code=O%28n%5E2%29&id=hxC3d)

注:

  • 等差数列求和为 数据结构与算法1 - 图315

例3 - 递归快排

快速排序分区没分好的极端情况

数据结构与算法1 - 图316%20%3D%20T(n-1)%20%2B%20T(1)%20%2B%20n#card=math&code=T%28n%29%20%3D%20T%28n-1%29%20%2B%20T%281%29%20%2B%20n&id=HWIqL),数据结构与算法1 - 图317%20%3D%20c#card=math&code=T%281%29%20%3D%20c&id=EGaKY)

数据结构与算法1 - 图318%20%3D%20T(n-1)%20%2B%20c%20%2B%20n#card=math&code=T%28n%29%20%3D%20T%28n-1%29%20%2B%20c%20%2B%20n&id=D2TyS)

下面为展开过程

数据结构与算法1 - 图319%20%3D%20T(n-2)%20%2B%20c%20%2B%20(n-1)%20%2B%20c%20%2B%20n#card=math&code=T%28n%29%20%3D%20T%28n-2%29%20%2B%20c%20%2B%20%28n-1%29%20%2B%20c%20%2B%20n&id=o841W)

数据结构与算法1 - 图320%20%3D%20T(n-3)%20%2B%20c%20%2B%20(n-2)%20%2B%20c%20%2B%20(n-1)%20%2B%20c%20%2B%20n#card=math&code=T%28n%29%20%3D%20T%28n-3%29%20%2B%20c%20%2B%20%28n-2%29%20%2B%20c%20%2B%20%28n-1%29%20%2B%20c%20%2B%20n&id=XZ6kY)

数据结构与算法1 - 图321%20%3D%20T(n-(n-1))%20%2B%20(n-1)c%20%2B%202%2B…%2Bn%20%3D%20%5Cfrac%7Bn%5E2%7D%7B2%7D%20%2B%20%5Cfrac%7B2cn%2Bn%7D%7B2%7D%20-1#card=math&code=T%28n%29%20%3D%20T%28n-%28n-1%29%29%20%2B%20%28n-1%29c%20%2B%202%2B…%2Bn%20%3D%20%5Cfrac%7Bn%5E2%7D%7B2%7D%20%2B%20%5Cfrac%7B2cn%2Bn%7D%7B2%7D%20-1&id=JNyPG)

时间复杂度 数据结构与算法1 - 图322#card=math&code=O%28n%5E2%29&id=y2sNA)

不会推导的同学可以进入 https://www.wolframalpha.com/

  • 例1 输入 f(n) = f(n - 1) + c, f(1) = c
  • 例2 输入 f(n) = f(n - 1) + n, f(1) = c
  • 例3 输入 f(n) = f(n - 1) + n + c, f(1) = c

2.4 队列

概述

计算机科学中,queue 是以顺序的方式维护的一组数据集合,在一端添加数据,从另一端移除数据。习惯来说,添加的一端称为,移除的一端称为,就如同生活中的排队买商品

In computer science, a queue is a collection of entities that are maintained in a sequence and can be modified by the addition of entities at one end of the sequence and the removal of entities from the other end of the sequence

先定义一个简化的队列接口

  1. public interface Queue<E> {
  2. /**
  3. * 向队列尾插入值
  4. * @param value 待插入值
  5. * @return 插入成功返回 true, 插入失败返回 false
  6. */
  7. boolean offer(E value);
  8. /**
  9. * 从对列头获取值, 并移除
  10. * @return 如果队列非空返回对头值, 否则返回 null
  11. */
  12. E poll();
  13. /**
  14. * 从对列头获取值, 不移除
  15. * @return 如果队列非空返回对头值, 否则返回 null
  16. */
  17. E peek();
  18. /**
  19. * 检查队列是否为空
  20. * @return 空返回 true, 否则返回 false
  21. */
  22. boolean isEmpty();
  23. /**
  24. * 检查队列是否已满
  25. * @return 满返回 true, 否则返回 false
  26. */
  27. boolean isFull();
  28. }

链表实现

下面以单向环形带哨兵链表方式来实现队列

数据结构与算法1 - 图323

数据结构与算法1 - 图324

数据结构与算法1 - 图325

代码

  1. public class LinkedListQueue<E>
  2. implements Queue<E>, Iterable<E> {
  3. private static class Node<E> {
  4. E value;
  5. Node<E> next;
  6. public Node(E value, Node<E> next) {
  7. this.value = value;
  8. this.next = next;
  9. }
  10. }
  11. private Node<E> head = new Node<>(null, null);
  12. private Node<E> tail = head;
  13. private int size = 0;
  14. private int capacity = Integer.MAX_VALUE;
  15. {
  16. tail.next = head;
  17. }
  18. public LinkedListQueue() {
  19. }
  20. public LinkedListQueue(int capacity) {
  21. this.capacity = capacity;
  22. }
  23. @Override
  24. public boolean offer(E value) {
  25. if (isFull()) {
  26. return false;
  27. }
  28. Node<E> added = new Node<>(value, head);
  29. tail.next = added;
  30. tail = added;
  31. size++;
  32. return true;
  33. }
  34. @Override
  35. public E poll() {
  36. if (isEmpty()) {
  37. return null;
  38. }
  39. Node<E> first = head.next;
  40. head.next = first.next;
  41. if (first == tail) {
  42. tail = head;
  43. }
  44. size--;
  45. return first.value;
  46. }
  47. @Override
  48. public E peek() {
  49. if (isEmpty()) {
  50. return null;
  51. }
  52. return head.next.value;
  53. }
  54. @Override
  55. public boolean isEmpty() {
  56. return head == tail;
  57. }
  58. @Override
  59. public boolean isFull() {
  60. return size == capacity;
  61. }
  62. @Override
  63. public Iterator<E> iterator() {
  64. return new Iterator<E>() {
  65. Node<E> p = head.next;
  66. @Override
  67. public boolean hasNext() {
  68. return p != head;
  69. }
  70. @Override
  71. public E next() {
  72. E value = p.value;
  73. p = p.next;
  74. return value;
  75. }
  76. };
  77. }
  78. }

环形数组实现

好处

  1. 对比普通数组,起点和终点更为自由,不用考虑数据移动
  2. “环”意味着不会存在【越界】问题
  3. 数组性能更佳
  4. 环形数组比较适合实现有界队列、RingBuffer 等

数据结构与算法1 - 图326

下标计算

例如,数组长度是 5,当前位置是 3 ,向前走 2 步,此时下标为 数据结构与算法1 - 图327%5C%255%20%3D%200#card=math&code=%283%20%2B%202%29%5C%255%20%3D%200&id=G5iGE)

数据结构与算法1 - 图328

数据结构与算法1 - 图329%20%5C%25%20length%0A#card=math&code=%28cur%20%2B%20step%29%20%5C%25%20length%0A&id=JyGrO)

  • cur 当前指针位置
  • step 前进步数
  • length 数组长度

注意:

  • 如果 step = 1,也就是一次走一步,可以在 >= length 时重置为 0 即可

判断空

数据结构与算法1 - 图330

判断满

数据结构与算法1 - 图331

满之后的策略可以根据业务需求决定

  • 例如我们要实现的环形队列,满之后就拒绝入队

代码

  1. public class ArrayQueue<E> implements Queue<E>, Iterable<E>{
  2. private int head = 0;
  3. private int tail = 0;
  4. private final E[] array;
  5. private final int length;
  6. @SuppressWarnings("all")
  7. public ArrayQueue(int capacity) {
  8. length = capacity + 1;
  9. array = (E[]) new Object[length];
  10. }
  11. @Override
  12. public boolean offer(E value) {
  13. if (isFull()) {
  14. return false;
  15. }
  16. array[tail] = value;
  17. tail = (tail + 1) % length;
  18. return true;
  19. }
  20. @Override
  21. public E poll() {
  22. if (isEmpty()) {
  23. return null;
  24. }
  25. E value = array[head];
  26. head = (head + 1) % length;
  27. return value;
  28. }
  29. @Override
  30. public E peek() {
  31. if (isEmpty()) {
  32. return null;
  33. }
  34. return array[head];
  35. }
  36. @Override
  37. public boolean isEmpty() {
  38. return tail == head;
  39. }
  40. @Override
  41. public boolean isFull() {
  42. return (tail + 1) % length == head;
  43. }
  44. @Override
  45. public Iterator<E> iterator() {
  46. return new Iterator<E>() {
  47. int p = head;
  48. @Override
  49. public boolean hasNext() {
  50. return p != tail;
  51. }
  52. @Override
  53. public E next() {
  54. E value = array[p];
  55. p = (p + 1) % array.length;
  56. return value;
  57. }
  58. };
  59. }
  60. }

判断空、满方法2

引入 size

  1. public class ArrayQueue2<E> implements Queue<E>, Iterable<E> {
  2. private int head = 0;
  3. private int tail = 0;
  4. private final E[] array;
  5. private final int capacity;
  6. private int size = 0;
  7. @SuppressWarnings("all")
  8. public ArrayQueue2(int capacity) {
  9. this.capacity = capacity;
  10. array = (E[]) new Object[capacity];
  11. }
  12. @Override
  13. public boolean offer(E value) {
  14. if (isFull()) {
  15. return false;
  16. }
  17. array[tail] = value;
  18. tail = (tail + 1) % capacity;
  19. size++;
  20. return true;
  21. }
  22. @Override
  23. public E poll() {
  24. if (isEmpty()) {
  25. return null;
  26. }
  27. E value = array[head];
  28. head = (head + 1) % capacity;
  29. size--;
  30. return value;
  31. }
  32. @Override
  33. public E peek() {
  34. if (isEmpty()) {
  35. return null;
  36. }
  37. return array[head];
  38. }
  39. @Override
  40. public boolean isEmpty() {
  41. return size == 0;
  42. }
  43. @Override
  44. public boolean isFull() {
  45. return size == capacity;
  46. }
  47. @Override
  48. public Iterator<E> iterator() {
  49. return new Iterator<E>() {
  50. int p = head;
  51. @Override
  52. public boolean hasNext() {
  53. return p != tail;
  54. }
  55. @Override
  56. public E next() {
  57. E value = array[p];
  58. p = (p + 1) % capacity;
  59. return value;
  60. }
  61. };
  62. }
  63. }

判断空、满方法3

  • head 和 tail 不断递增,用到索引时,再用它们进行计算,两个问题
    • 如何保证 head 和 tail 自增超过正整数最大值的正确性
    • 如何让取模运算性能更高
  • 答案:让 capacity 为 2 的幂
  1. public class ArrayQueue3<E> implements Queue<E>, Iterable<E> {
  2. private int head = 0;
  3. private int tail = 0;
  4. private final E[] array;
  5. private final int capacity;
  6. @SuppressWarnings("all")
  7. public ArrayQueue3(int capacity) {
  8. if ((capacity & capacity - 1) != 0) {
  9. throw new IllegalArgumentException("capacity 必须为 2 的幂");
  10. }
  11. this.capacity = capacity;
  12. array = (E[]) new Object[this.capacity];
  13. }
  14. @Override
  15. public boolean offer(E value) {
  16. if (isFull()) {
  17. return false;
  18. }
  19. array[tail & capacity - 1] = value;
  20. tail++;
  21. return true;
  22. }
  23. @Override
  24. public E poll() {
  25. if (isEmpty()) {
  26. return null;
  27. }
  28. E value = array[head & capacity - 1];
  29. head++;
  30. return value;
  31. }
  32. @Override
  33. public E peek() {
  34. if (isEmpty()) {
  35. return null;
  36. }
  37. return array[head & capacity - 1];
  38. }
  39. @Override
  40. public boolean isEmpty() {
  41. return tail - head == 0;
  42. }
  43. @Override
  44. public boolean isFull() {
  45. return tail - head == capacity;
  46. }
  47. @Override
  48. public Iterator<E> iterator() {
  49. return new Iterator<E>() {
  50. int p = head;
  51. @Override
  52. public boolean hasNext() {
  53. return p != tail;
  54. }
  55. @Override
  56. public E next() {
  57. E value = array[p & capacity - 1];
  58. p++;
  59. return value;
  60. }
  61. };
  62. }
  63. }

2.5 栈

概述

计算机科学中,stack 是一种线性的数据结构,只能在其一端添加数据和移除数据。习惯来说,这一端称之为栈顶,另一端不能操作数据的称之为栈底,就如同生活中的一摞书

先提供一个栈接口

  1. public interface Stack<E> {
  2. /**
  3. * 向栈顶压入元素
  4. * @param value 待压入值
  5. * @return 压入成功返回 true, 否则返回 false
  6. */
  7. boolean push(E value);
  8. /**
  9. * 从栈顶弹出元素
  10. * @return 栈非空返回栈顶元素, 栈为空返回 null
  11. */
  12. E pop();
  13. /**
  14. * 返回栈顶元素, 不弹出
  15. * @return 栈非空返回栈顶元素, 栈为空返回 null
  16. */
  17. E peek();
  18. /**
  19. * 判断栈是否为空
  20. * @return 空返回 true, 否则返回 false
  21. */
  22. boolean isEmpty();
  23. /**
  24. * 判断栈是否已满
  25. * @return 满返回 true, 否则返回 false
  26. */
  27. boolean isFull();
  28. }

链表实现

  1. public class LinkedListStack<E> implements Stack<E>, Iterable<E> {
  2. private final int capacity;
  3. private int size;
  4. private final Node<E> head = new Node<>(null, null);
  5. public LinkedListStack(int capacity) {
  6. this.capacity = capacity;
  7. }
  8. @Override
  9. public boolean push(E value) {
  10. if (isFull()) {
  11. return false;
  12. }
  13. head.next = new Node<>(value, head.next);
  14. size++;
  15. return true;
  16. }
  17. @Override
  18. public E pop() {
  19. if (isEmpty()) {
  20. return null;
  21. }
  22. Node<E> first = head.next;
  23. head.next = first.next;
  24. size--;
  25. return first.value;
  26. }
  27. @Override
  28. public E peek() {
  29. if (isEmpty()) {
  30. return null;
  31. }
  32. return head.next.value;
  33. }
  34. @Override
  35. public boolean isEmpty() {
  36. return head.next == null;
  37. }
  38. @Override
  39. public boolean isFull() {
  40. return size == capacity;
  41. }
  42. @Override
  43. public Iterator<E> iterator() {
  44. return new Iterator<E>() {
  45. Node<E> p = head.next;
  46. @Override
  47. public boolean hasNext() {
  48. return p != null;
  49. }
  50. @Override
  51. public E next() {
  52. E value = p.value;
  53. p = p.next;
  54. return value;
  55. }
  56. };
  57. }
  58. static class Node<E> {
  59. E value;
  60. Node<E> next;
  61. public Node(E value, Node<E> next) {
  62. this.value = value;
  63. this.next = next;
  64. }
  65. }
  66. }

数组实现

  1. public class ArrayStack<E> implements Stack<E>, Iterable<E>{
  2. private final E[] array;
  3. private int top = 0;
  4. @SuppressWarnings("all")
  5. public ArrayStack(int capacity) {
  6. this.array = (E[]) new Object[capacity];
  7. }
  8. @Override
  9. public boolean push(E value) {
  10. if (isFull()) {
  11. return false;
  12. }
  13. array[top++] = value;
  14. return true;
  15. }
  16. @Override
  17. public E pop() {
  18. if (isEmpty()) {
  19. return null;
  20. }
  21. return array[--top];
  22. }
  23. @Override
  24. public E peek() {
  25. if (isEmpty()) {
  26. return null;
  27. }
  28. return array[top-1];
  29. }
  30. @Override
  31. public boolean isEmpty() {
  32. return top == 0;
  33. }
  34. @Override
  35. public boolean isFull() {
  36. return top == array.length;
  37. }
  38. @Override
  39. public Iterator<E> iterator() {
  40. return new Iterator<E>() {
  41. int p = top;
  42. @Override
  43. public boolean hasNext() {
  44. return p > 0;
  45. }
  46. @Override
  47. public E next() {
  48. return array[--p];
  49. }
  50. };
  51. }
  52. }

应用

模拟如下方法调用

  1. public static void main(String[] args) {
  2. System.out.println("main1");
  3. System.out.println("main2");
  4. method1();
  5. method2();
  6. System.out.println("main3");
  7. }
  8. public static void method1() {
  9. System.out.println("method1");
  10. method3();
  11. }
  12. public static void method2() {
  13. System.out.println("method2");
  14. }
  15. public static void method3() {
  16. System.out.println("method3");
  17. }

模拟代码

  1. public class CPU {
  2. static class Frame {
  3. int exit;
  4. public Frame(int exit) {
  5. this.exit = exit;
  6. }
  7. }
  8. static int pc = 1; // 模拟程序计数器 Program counter
  9. static ArrayStack<Frame> stack = new ArrayStack<>(100); // 模拟方法调用栈
  10. public static void main(String[] args) {
  11. stack.push(new Frame(-1));
  12. while (!stack.isEmpty()) {
  13. switch (pc) {
  14. case 1 -> {
  15. System.out.println("main1");
  16. pc++;
  17. }
  18. case 2 -> {
  19. System.out.println("main2");
  20. pc++;
  21. }
  22. case 3 -> {
  23. stack.push(new Frame(pc + 1));
  24. pc = 100;
  25. }
  26. case 4 -> {
  27. stack.push(new Frame(pc + 1));
  28. pc = 200;
  29. }
  30. case 5 -> {
  31. System.out.println("main3");
  32. pc = stack.pop().exit;
  33. }
  34. case 100 -> {
  35. System.out.println("method1");
  36. stack.push(new Frame(pc + 1));
  37. pc = 300;
  38. }
  39. case 101 -> {
  40. pc = stack.pop().exit;
  41. }
  42. case 200 -> {
  43. System.out.println("method2");
  44. pc = stack.pop().exit;
  45. }
  46. case 300 -> {
  47. System.out.println("method3");
  48. pc = stack.pop().exit;
  49. }
  50. }
  51. }
  52. }
  53. }

2.6 双端队列

概述

双端队列、队列、栈对比

定义 特点
队列 一端删除(头)另一端添加(尾) First In First Out
一端删除和添加(顶) Last In First Out
双端队列 两端都可以删除、添加
优先级队列 优先级高者先出队
延时队列 根据延时时间确定优先级
并发非阻塞队列 队列空或满时不阻塞
并发阻塞队列 队列空时删除阻塞、队列满时添加阻塞

注1:

  • Java 中 LinkedList 即为典型双端队列实现,不过它同时实现了 Queue 接口,也提供了栈的 push pop 等方法

注2:

  • 不同语言,操作双端队列的方法命名有所不同,参见下表
  • 吐槽一下 leetCode 命名比较 low
  • 常见的单词还有 enqueue 入队、dequeue 出队
操作 Java JavaScript C++ leetCode 641
尾部插入 offerLast push push_back insertLast
头部插入 offerFirst unshift push_front insertFront
尾部移除 pollLast pop pop_back deleteLast
头部移除 pollFirst shift pop_front deleteFront
尾部获取 peekLast at(-1) back getRear
头部获取 peekFirst at(0) front getFront

接口定义

  1. public interface Deque<E> {
  2. boolean offerFirst(E e);
  3. boolean offerLast(E e);
  4. E pollFirst();
  5. E pollLast();
  6. E peekFirst();
  7. E peekLast();
  8. boolean isEmpty();
  9. boolean isFull();
  10. }

链表实现

  1. /**
  2. * 基于环形链表的双端队列
  3. * @param <E> 元素类型
  4. */
  5. public class LinkedListDeque<E> implements Deque<E>, Iterable<E> {
  6. @Override
  7. public boolean offerFirst(E e) {
  8. if (isFull()) {
  9. return false;
  10. }
  11. size++;
  12. Node<E> a = sentinel;
  13. Node<E> b = sentinel.next;
  14. Node<E> offered = new Node<>(a, e, b);
  15. a.next = offered;
  16. b.prev = offered;
  17. return true;
  18. }
  19. @Override
  20. public boolean offerLast(E e) {
  21. if (isFull()) {
  22. return false;
  23. }
  24. size++;
  25. Node<E> a = sentinel.prev;
  26. Node<E> b = sentinel;
  27. Node<E> offered = new Node<>(a, e, b);
  28. a.next = offered;
  29. b.prev = offered;
  30. return true;
  31. }
  32. @Override
  33. public E pollFirst() {
  34. if (isEmpty()) {
  35. return null;
  36. }
  37. Node<E> a = sentinel;
  38. Node<E> polled = sentinel.next;
  39. Node<E> b = polled.next;
  40. a.next = b;
  41. b.prev = a;
  42. size--;
  43. return polled.value;
  44. }
  45. @Override
  46. public E pollLast() {
  47. if (isEmpty()) {
  48. return null;
  49. }
  50. Node<E> polled = sentinel.prev;
  51. Node<E> a = polled.prev;
  52. Node<E> b = sentinel;
  53. a.next = b;
  54. b.prev = a;
  55. size--;
  56. return polled.value;
  57. }
  58. @Override
  59. public E peekFirst() {
  60. if (isEmpty()) {
  61. return null;
  62. }
  63. return sentinel.next.value;
  64. }
  65. @Override
  66. public E peekLast() {
  67. if (isEmpty()) {
  68. return null;
  69. }
  70. return sentinel.prev.value;
  71. }
  72. @Override
  73. public boolean isEmpty() {
  74. return size == 0;
  75. }
  76. @Override
  77. public boolean isFull() {
  78. return size == capacity;
  79. }
  80. @Override
  81. public Iterator<E> iterator() {
  82. return new Iterator<E>() {
  83. Node<E> p = sentinel.next;
  84. @Override
  85. public boolean hasNext() {
  86. return p != sentinel;
  87. }
  88. @Override
  89. public E next() {
  90. E value = p.value;
  91. p = p.next;
  92. return value;
  93. }
  94. };
  95. }
  96. static class Node<E> {
  97. Node<E> prev;
  98. E value;
  99. Node<E> next;
  100. public Node(Node<E> prev, E value, Node<E> next) {
  101. this.prev = prev;
  102. this.value = value;
  103. this.next = next;
  104. }
  105. }
  106. Node<E> sentinel = new Node<>(null, null, null);
  107. int capacity;
  108. int size;
  109. public LinkedListDeque(int capacity) {
  110. sentinel.next = sentinel;
  111. sentinel.prev = sentinel;
  112. this.capacity = capacity;
  113. }
  114. }

数组实现

  1. /**
  2. * 基于循环数组实现, 特点
  3. * <ul>
  4. * <li>tail 停下来的位置不存储, 会浪费一个位置</li>
  5. * </ul>
  6. * @param <E>
  7. */
  8. public class ArrayDeque1<E> implements Deque<E>, Iterable<E> {
  9. /*
  10. h
  11. t
  12. 0 1 2 3
  13. b a
  14. */
  15. @Override
  16. public boolean offerFirst(E e) {
  17. if (isFull()) {
  18. return false;
  19. }
  20. head = dec(head, array.length);
  21. array[head] = e;
  22. return true;
  23. }
  24. @Override
  25. public boolean offerLast(E e) {
  26. if (isFull()) {
  27. return false;
  28. }
  29. array[tail] = e;
  30. tail = inc(tail, array.length);
  31. return true;
  32. }
  33. @Override
  34. public E pollFirst() {
  35. if (isEmpty()) {
  36. return null;
  37. }
  38. E e = array[head];
  39. array[head] = null;
  40. head = inc(head, array.length);
  41. return e;
  42. }
  43. @Override
  44. public E pollLast() {
  45. if (isEmpty()) {
  46. return null;
  47. }
  48. tail = dec(tail, array.length);
  49. E e = array[tail];
  50. array[tail] = null;
  51. return e;
  52. }
  53. @Override
  54. public E peekFirst() {
  55. if (isEmpty()) {
  56. return null;
  57. }
  58. return array[head];
  59. }
  60. @Override
  61. public E peekLast() {
  62. if (isEmpty()) {
  63. return null;
  64. }
  65. return array[dec(tail, array.length)];
  66. }
  67. @Override
  68. public boolean isEmpty() {
  69. return head == tail;
  70. }
  71. @Override
  72. public boolean isFull() {
  73. if (tail > head) {
  74. return tail - head == array.length - 1;
  75. } else if (tail < head) {
  76. return head - tail == 1;
  77. } else {
  78. return false;
  79. }
  80. }
  81. @Override
  82. public Iterator<E> iterator() {
  83. return new Iterator<E>() {
  84. int p = head;
  85. @Override
  86. public boolean hasNext() {
  87. return p != tail;
  88. }
  89. @Override
  90. public E next() {
  91. E e = array[p];
  92. p = inc(p, array.length);
  93. return e;
  94. }
  95. };
  96. }
  97. E[] array;
  98. int head;
  99. int tail;
  100. @SuppressWarnings("unchecked")
  101. public ArrayDeque1(int capacity) {
  102. array = (E[]) new Object[capacity + 1];
  103. }
  104. static int inc(int i, int length) {
  105. if (i + 1 >= length) {
  106. return 0;
  107. }
  108. return i + 1;
  109. }
  110. static int dec(int i, int length) {
  111. if (i - 1 < 0) {
  112. return length - 1;
  113. }
  114. return i - 1;
  115. }
  116. }

数组实现中,如果存储的是基本类型,那么无需考虑内存释放,例如

数据结构与算法1 - 图332

但如果存储的是引用类型,应当设置该位置的引用为 null,以便内存及时释放

数据结构与算法1 - 图333

2.7 优先级队列

无序数组实现

要点

  1. 入队保持顺序
  2. 出队前找到优先级最高的出队,相当于一次选择排序
  1. public class PriorityQueue1<E extends Priority> implements Queue<E> {
  2. Priority[] array;
  3. int size;
  4. public PriorityQueue1(int capacity) {
  5. array = new Priority[capacity];
  6. }
  7. @Override // O(1)
  8. public boolean offer(E e) {
  9. if (isFull()) {
  10. return false;
  11. }
  12. array[size++] = e;
  13. return true;
  14. }
  15. // 返回优先级最高的索引值
  16. private int selectMax() {
  17. int max = 0;
  18. for (int i = 1; i < size; i++) {
  19. if (array[i].priority() > array[max].priority()) {
  20. max = i;
  21. }
  22. }
  23. return max;
  24. }
  25. @Override // O(n)
  26. public E poll() {
  27. if (isEmpty()) {
  28. return null;
  29. }
  30. int max = selectMax();
  31. E e = (E) array[max];
  32. remove(max);
  33. return e;
  34. }
  35. private void remove(int index) {
  36. if (index < size - 1) {
  37. System.arraycopy(array, index + 1,
  38. array, index, size - 1 - index);
  39. }
  40. array[--size] = null; // help GC
  41. }
  42. @Override
  43. public E peek() {
  44. if (isEmpty()) {
  45. return null;
  46. }
  47. int max = selectMax();
  48. return (E) array[max];
  49. }
  50. @Override
  51. public boolean isEmpty() {
  52. return size == 0;
  53. }
  54. @Override
  55. public boolean isFull() {
  56. return size == array.length;
  57. }
  58. }
  • 视频中忘记了 help GC,注意一下

有序数组实现

要点

  1. 入队后排好序,优先级最高的排列在尾部
  2. 出队只需删除尾部元素即可

    1. public class PriorityQueue2<E extends Priority> implements Queue<E> {
    2. Priority[] array;
    3. int size;
    4. public PriorityQueue2(int capacity) {
    5. array = new Priority[capacity];
    6. }
    7. // O(n)
    8. @Override
    9. public boolean offer(E e) {
    10. if (isFull()) {
    11. return false;
    12. }
    13. insert(e);
    14. size++;
    15. return true;
    16. }
    17. // 一轮插入排序
    18. private void insert(E e) {
    19. int i = size - 1;
    20. while (i >= 0 && array[i].priority() > e.priority()) {
    21. array[i + 1] = array[i];
    22. i--;
    23. }
    24. array[i + 1] = e;
    25. }
    26. // O(1)
    27. @Override
    28. public E poll() {
    29. if (isEmpty()) {
    30. return null;
    31. }
    32. E e = (E) array[size - 1];
    33. array[--size] = null; // help GC
    34. return e;
    35. }
    36. @Override
    37. public E peek() {
    38. if (isEmpty()) {
    39. return null;
    40. }
    41. return (E) array[size - 1];
    42. }
    43. @Override
    44. public boolean isEmpty() {
    45. return size == 0;
    46. }
    47. @Override
    48. public boolean isFull() {
    49. return size == array.length;
    50. }
    51. }

堆实现

计算机科学中,堆是一种基于树的数据结构,通常用完全二叉树实现。堆的特性如下

  • 在大顶堆中,任意节点 C 与它的父节点 P 符合 数据结构与算法1 - 图334
  • 而小顶堆中,任意节点 C 与它的父节点 P 符合 数据结构与算法1 - 图335
  • 最顶层的节点(没有父亲)称之为 root 根节点

    In computer science, a heap is a specialized tree-based data structure which is essentially an almost complete tree that satisfies the heap property: in a max heap, for any given node C, if P is a parent node of C, then the key (the value) of P is greater than or equal to the key of C. In a min heap, the key of P is less than or equal to the key of C. The node at the “top” of the heap (with no parents) is called the root node

例1 - 满二叉树(Full Binary Tree)特点:每一层都是填满的
数据结构与算法1 - 图336

例2 - 完全二叉树(Complete Binary Tree)特点:最后一层可能未填满,靠左对齐
数据结构与算法1 - 图337

例3 - 大顶堆
数据结构与算法1 - 图338

例4 - 小顶堆
数据结构与算法1 - 图339

完全二叉树可以使用数组来表示
数据结构与算法1 - 图340

特征

  • 如果从索引 0 开始存储节点数据
    • 节点 数据结构与算法1 - 图341 的父节点为 数据结构与算法1 - 图342%2F2)#card=math&code=floor%28%28i-1%29%2F2%29&id=I0Roj),当 数据结构与算法1 - 图343
    • 节点 数据结构与算法1 - 图344 的左子节点为 数据结构与算法1 - 图345,右子节点为 数据结构与算法1 - 图346,当然它们得 数据结构与算法1 - 图347
  • 如果从索引 1 开始存储节点数据
    • 节点 数据结构与算法1 - 图348 的父节点为 数据结构与算法1 - 图349#card=math&code=floor%28i%2F2%29&id=c4Stf),当 数据结构与算法1 - 图350
    • 节点 数据结构与算法1 - 图351 的左子节点为 数据结构与算法1 - 图352,右子节点为 数据结构与算法1 - 图353,同样得 数据结构与算法1 - 图354

代码

  1. public class PriorityQueue4<E extends Priority> implements Queue<E> {
  2. Priority[] array;
  3. int size;
  4. public PriorityQueue4(int capacity) {
  5. array = new Priority[capacity];
  6. }
  7. @Override
  8. public boolean offer(E offered) {
  9. if (isFull()) {
  10. return false;
  11. }
  12. int child = size++;
  13. int parent = (child - 1) / 2;
  14. while (child > 0 && offered.priority() > array[parent].priority()) {
  15. array[child] = array[parent];
  16. child = parent;
  17. parent = (child - 1) / 2;
  18. }
  19. array[child] = offered;
  20. return true;
  21. }
  22. private void swap(int i, int j) {
  23. Priority t = array[i];
  24. array[i] = array[j];
  25. array[j] = t;
  26. }
  27. @Override
  28. public E poll() {
  29. if (isEmpty()) {
  30. return null;
  31. }
  32. swap(0, size - 1);
  33. size--;
  34. Priority e = array[size];
  35. array[size] = null;
  36. shiftDown(0);
  37. return (E) e;
  38. }
  39. void shiftDown(int parent) {
  40. int left = 2 * parent + 1;
  41. int right = left + 1;
  42. int max = parent;
  43. if (left < size && array[left].priority() > array[max].priority()) {
  44. max = left;
  45. }
  46. if (right < size && array[right].priority() > array[max].priority()) {
  47. max = right;
  48. }
  49. if (max != parent) {
  50. swap(max, parent);
  51. shiftDown(max);
  52. }
  53. }
  54. @Override
  55. public E peek() {
  56. if (isEmpty()) {
  57. return null;
  58. }
  59. return (E) array[0];
  60. }
  61. @Override
  62. public boolean isEmpty() {
  63. return size == 0;
  64. }
  65. @Override
  66. public boolean isFull() {
  67. return size == array.length;
  68. }
  69. }

2.8 阻塞队列

之前的队列在很多场景下都不能很好地工作,例如

  1. 大部分场景要求分离向队列放入(生产者)、从队列拿出(消费者)两个角色、它们得由不同的线程来担当,而之前的实现根本没有考虑线程安全问题
  2. 队列为空,那么在之前的实现里会返回 null,如果就是硬要拿到一个元素呢?只能不断循环尝试
  3. 队列为满,那么再之前的实现里会返回 false,如果就是硬要塞入一个元素呢?只能不断循环尝试

因此我们需要解决的问题有

  1. 用锁保证线程安全
  2. 用条件变量让等待非空线程等待不满线程进入等待状态,而不是不断循环尝试,让 CPU 空转

有同学对线程安全还没有足够的认识,下面举一个反例,两个线程都要执行入队操作(几乎在同一时刻)

  1. public class TestThreadUnsafe {
  2. private final String[] array = new String[10];
  3. private int tail = 0;
  4. public void offer(String e) {
  5. array[tail] = e;
  6. tail++;
  7. }
  8. @Override
  9. public String toString() {
  10. return Arrays.toString(array);
  11. }
  12. public static void main(String[] args) {
  13. TestThreadUnsafe queue = new TestThreadUnsafe();
  14. new Thread(()-> queue.offer("e1"), "t1").start();
  15. new Thread(()-> queue.offer("e2"), "t2").start();
  16. }
  17. }

执行的时间序列如下,假设初始状态 tail = 0,在执行过程中由于 CPU 在两个线程之间切换,造成了指令交错

线程1 线程2 说明
array[tail]=e1 线程1 向 tail 位置加入 e1 这个元素,但还没来得及执行 tail++
array[tail]=e2 线程2 向 tail 位置加入 e2 这个元素,覆盖掉了 e1
tail++ tail 自增为1
tail++ tail 自增为2
最后状态 tail 为 2,数组为 [e2, null, null …]

糟糕的是,由于指令交错的顺序不同,得到的结果不止以上一种,宏观上造成混乱的效果

单锁实现

Java 中要防止代码段交错执行,需要使用锁,有两种选择

  • synchronized 代码块,属于关键字级别提供锁保护,功能少
  • ReentrantLock 类,功能丰富

以 ReentrantLock 为例

  1. ReentrantLock lock = new ReentrantLock();
  2. public void offer(String e) {
  3. lock.lockInterruptibly();
  4. try {
  5. array[tail] = e;
  6. tail++;
  7. } finally {
  8. lock.unlock();
  9. }
  10. }

只要两个线程执行上段代码时,锁对象是同一个,就能保证 try 块内的代码的执行不会出现指令交错现象,即执行顺序只可能是下面两种情况之一

线程1 线程2 说明
lock.lockInterruptibly() t1对锁对象上锁
array[tail]=e1
lock.lockInterruptibly() 即使 CPU 切换到线程2,但由于t1已经对该对象上锁,因此线程2卡在这儿进不去
tail++ 切换回线程1 执行后续代码
lock.unlock() 线程1 解锁
array[tail]=e2 线程2 此时才能获得锁,执行它的代码
tail++
  • 另一种情况是线程2 先获得锁,线程1 被挡在外面
  • 要明白保护的本质,本例中是保护的是 tail 位置读写的安全

事情还没有完,上面的例子是队列还没有放满的情况,考虑下面的代码(这回锁同时保护了 tail 和 size 的读写安全)

  1. ReentrantLock lock = new ReentrantLock();
  2. int size = 0;
  3. public void offer(String e) {
  4. lock.lockInterruptibly();
  5. try {
  6. if(isFull()) {
  7. // 满了怎么办?
  8. }
  9. array[tail] = e;
  10. tail++;
  11. size++;
  12. } finally {
  13. lock.unlock();
  14. }
  15. }
  16. private boolean isFull() {
  17. return size == array.length;
  18. }

之前是返回 false 表示添加失败,前面分析过想达到这么一种效果:

  • 在队列满时,不是立刻返回,而是当前线程进入等待
  • 什么时候队列不满了,再唤醒这个等待的线程,从上次的代码处继续向下运行

ReentrantLock 可以配合条件变量来实现,代码进化为

  1. ReentrantLock lock = new ReentrantLock();
  2. Condition tailWaits = lock.newCondition(); // 条件变量
  3. int size = 0;
  4. public void offer(String e) {
  5. lock.lockInterruptibly();
  6. try {
  7. while (isFull()) {
  8. tailWaits.await(); // 当队列满时, 当前线程进入 tailWaits 等待
  9. }
  10. array[tail] = e;
  11. tail++;
  12. size++;
  13. } finally {
  14. lock.unlock();
  15. }
  16. }
  17. private boolean isFull() {
  18. return size == array.length;
  19. }
  • 条件变量底层也是个队列,用来存储这些需要等待的线程,当队列满了,就会将 offer 线程加入条件队列,并暂时释放锁
  • 将来我们的队列如果不满了(由 poll 线程那边得知)可以调用 tailWaits.signal() 来唤醒 tailWaits 中首个等待的线程,被唤醒的线程会再次抢到锁,从上次 await 处继续向下运行

思考为何要用 while 而不是 if,设队列容量是 3

操作前 offer(4) offer(5) poll() 操作后
[1 2 3] 队列满,进入tailWaits 等待 [1 2 3]
[1 2 3] 取走 1,队列不满,唤醒线程 [2 3]
[2 3] 抢先获得锁,发现不满,放入 5 [2 3 5]
[2 3 5] 从上次等待处直接向下执行 [2 3 5 ?]

关键点:

  • 从 tailWaits 中唤醒的线程,会与新来的 offer 的线程争抢锁,谁能抢到是不一定的,如果后者先抢到,就会导致条件又发生变化
  • 这种情况称之为虚假唤醒,唤醒后应该重新检查条件,看是不是得重新进入等待

最后的实现代码

  1. /**
  2. * 单锁实现
  3. * @param <E> 元素类型
  4. */
  5. public class BlockingQueue1<E> implements BlockingQueue<E> {
  6. private final E[] array;
  7. private int head = 0;
  8. private int tail = 0;
  9. private int size = 0; // 元素个数
  10. @SuppressWarnings("all")
  11. public BlockingQueue1(int capacity) {
  12. array = (E[]) new Object[capacity];
  13. }
  14. ReentrantLock lock = new ReentrantLock();
  15. Condition tailWaits = lock.newCondition();
  16. Condition headWaits = lock.newCondition();
  17. @Override
  18. public void offer(E e) throws InterruptedException {
  19. lock.lockInterruptibly();
  20. try {
  21. while (isFull()) {
  22. tailWaits.await();
  23. }
  24. array[tail] = e;
  25. if (++tail == array.length) {
  26. tail = 0;
  27. }
  28. size++;
  29. headWaits.signal();
  30. } finally {
  31. lock.unlock();
  32. }
  33. }
  34. @Override
  35. public void offer(E e, long timeout) throws InterruptedException {
  36. lock.lockInterruptibly();
  37. try {
  38. long t = TimeUnit.MILLISECONDS.toNanos(timeout);
  39. while (isFull()) {
  40. if (t <= 0) {
  41. return;
  42. }
  43. t = tailWaits.awaitNanos(t);
  44. }
  45. array[tail] = e;
  46. if (++tail == array.length) {
  47. tail = 0;
  48. }
  49. size++;
  50. headWaits.signal();
  51. } finally {
  52. lock.unlock();
  53. }
  54. }
  55. @Override
  56. public E poll() throws InterruptedException {
  57. lock.lockInterruptibly();
  58. try {
  59. while (isEmpty()) {
  60. headWaits.await();
  61. }
  62. E e = array[head];
  63. array[head] = null; // help GC
  64. if (++head == array.length) {
  65. head = 0;
  66. }
  67. size--;
  68. tailWaits.signal();
  69. return e;
  70. } finally {
  71. lock.unlock();
  72. }
  73. }
  74. private boolean isEmpty() {
  75. return size == 0;
  76. }
  77. private boolean isFull() {
  78. return size == array.length;
  79. }
  80. }
  • public void offer(E e, long timeout) throws InterruptedException 是带超时的版本,可以只等待一段时间,而不是永久等下去,类似的 poll 也可以做带超时的版本,这个留给大家了

注意

  • JDK 中 BlockingQueue 接口的方法命名与我的示例有些差异
    • 方法 offer(E e) 是非阻塞的实现,阻塞实现方法为 put(E e)
    • 方法 poll() 是非阻塞的实现,阻塞实现方法为 take()

双锁实现

单锁的缺点在于:

  • 生产和消费几乎是不冲突的,唯一冲突的是生产者和消费者它们有可能同时修改 size
  • 冲突的主要是生产者之间:多个 offer 线程修改 tail
  • 冲突的还有消费者之间:多个 poll 线程修改 head

如果希望进一步提高性能,可以用两把锁

  • 一把锁保护 tail
  • 另一把锁保护 head ```java ReentrantLock headLock = new ReentrantLock(); // 保护 head 的锁 Condition headWaits = headLock.newCondition(); // 队列空时,需要等待的线程集合

ReentrantLock tailLock = new ReentrantLock(); // 保护 tail 的锁 Condition tailWaits = tailLock.newCondition(); // 队列满时,需要等待的线程集合

  1. 先看看 offer 方法的初步实现
  2. ```java
  3. @Override
  4. public void offer(E e) throws InterruptedException {
  5. tailLock.lockInterruptibly();
  6. try {
  7. // 队列满等待
  8. while (isFull()) {
  9. tailWaits.await();
  10. }
  11. // 不满则入队
  12. array[tail] = e;
  13. if (++tail == array.length) {
  14. tail = 0;
  15. }
  16. // 修改 size (有问题)
  17. size++;
  18. } finally {
  19. tailLock.unlock();
  20. }
  21. }

上面代码的缺点是 size 并不受 tailLock 保护,tailLock 与 headLock 是两把不同的锁,并不能实现互斥的效果。因此,size 需要用下面的代码保证原子性

  1. AtomicInteger size = new AtomicInteger(0); // 保护 size 的原子变量
  2. size.getAndIncrement(); // 自增
  3. size.getAndDecrement(); // 自减

代码修改为

  1. @Override
  2. public void offer(E e) throws InterruptedException {
  3. tailLock.lockInterruptibly();
  4. try {
  5. // 队列满等待
  6. while (isFull()) {
  7. tailWaits.await();
  8. }
  9. // 不满则入队
  10. array[tail] = e;
  11. if (++tail == array.length) {
  12. tail = 0;
  13. }
  14. // 修改 size
  15. size.getAndIncrement();
  16. } finally {
  17. tailLock.unlock();
  18. }
  19. }

对称地,可以写出 poll 方法

  1. @Override
  2. public E poll() throws InterruptedException {
  3. E e;
  4. headLock.lockInterruptibly();
  5. try {
  6. // 队列空等待
  7. while (isEmpty()) {
  8. headWaits.await();
  9. }
  10. // 不空则出队
  11. e = array[head];
  12. if (++head == array.length) {
  13. head = 0;
  14. }
  15. // 修改 size
  16. size.getAndDecrement();
  17. } finally {
  18. headLock.unlock();
  19. }
  20. return e;
  21. }

下面来看一个难题,就是如何通知 headWaits 和 tailWaits 中等待的线程,比如 poll 方法拿走一个元素,通知 tailWaits:我拿走一个,不满了噢,你们可以放了,因此代码改为

  1. @Override
  2. public E poll() throws InterruptedException {
  3. E e;
  4. headLock.lockInterruptibly();
  5. try {
  6. // 队列空等待
  7. while (isEmpty()) {
  8. headWaits.await();
  9. }
  10. // 不空则出队
  11. e = array[head];
  12. if (++head == array.length) {
  13. head = 0;
  14. }
  15. // 修改 size
  16. size.getAndDecrement();
  17. // 通知 tailWaits 不满(有问题)
  18. tailWaits.signal();
  19. } finally {
  20. headLock.unlock();
  21. }
  22. return e;
  23. }

问题在于要使用这些条件变量的 await(), signal() 等方法需要先获得与之关联的锁,上面的代码若直接运行会出现以下错误

  1. java.lang.IllegalMonitorStateException

那有同学说,加上锁不就行了吗,于是写出了下面的代码
数据结构与算法1 - 图355

发现什么问题了?两把锁这么嵌套使用,非常容易出现死锁,如下所示
数据结构与算法1 - 图356

因此得避免嵌套,两段加锁的代码变成了下面平级的样子
数据结构与算法1 - 图357

性能还可以进一步提升

  1. 代码调整后 offer 并没有同时获取 tailLock 和 headLock 两把锁,因此两次加锁之间会有空隙,这个空隙内可能有其它的 offer 线程添加了更多的元素,那么这些线程都要执行 signal(),通知 poll 线程队列非空吗?
    • 每次调用 signal() 都需要这些 offer 线程先获得 headLock 锁,成本较高,要想法减少 offer 线程获得 headLock 锁的次数
    • 可以加一个条件:当 offer 增加前队列为空,即从 0 变化到不空,才由此 offer 线程来通知 headWaits,其它情况不归它管
  2. 队列从 0 变化到不空,会唤醒一个等待的 poll 线程,这个线程被唤醒后,肯定能拿到 headLock 锁,因此它具备了唤醒 headWaits 上其它 poll 线程的先决条件。如果检查出此时有其它 offer 线程新增了元素(不空,但不是从0变化而来),那么不妨由此 poll 线程来唤醒其它 poll 线程

这个技巧被称之为级联通知(cascading notifies),类似的原因

  1. 在 poll 时队列从满变化到不满,才由此 poll 线程来唤醒一个等待的 offer 线程,目的也是为了减少 poll 线程对 tailLock 上锁次数,剩下等待的 offer 线程由这个 offer 线程间接唤醒

最终的代码为

  1. public class BlockingQueue2<E> implements BlockingQueue<E> {
  2. private final E[] array;
  3. private int head = 0;
  4. private int tail = 0;
  5. private final AtomicInteger size = new AtomicInteger(0);
  6. ReentrantLock headLock = new ReentrantLock();
  7. Condition headWaits = headLock.newCondition();
  8. ReentrantLock tailLock = new ReentrantLock();
  9. Condition tailWaits = tailLock.newCondition();
  10. public BlockingQueue2(int capacity) {
  11. this.array = (E[]) new Object[capacity];
  12. }
  13. @Override
  14. public void offer(E e) throws InterruptedException {
  15. int c;
  16. tailLock.lockInterruptibly();
  17. try {
  18. while (isFull()) {
  19. tailWaits.await();
  20. }
  21. array[tail] = e;
  22. if (++tail == array.length) {
  23. tail = 0;
  24. }
  25. c = size.getAndIncrement();
  26. // a. 队列不满, 但不是从满->不满, 由此offer线程唤醒其它offer线程
  27. if (c + 1 < array.length) {
  28. tailWaits.signal();
  29. }
  30. } finally {
  31. tailLock.unlock();
  32. }
  33. // b. 从0->不空, 由此offer线程唤醒等待的poll线程
  34. if (c == 0) {
  35. headLock.lock();
  36. try {
  37. headWaits.signal();
  38. } finally {
  39. headLock.unlock();
  40. }
  41. }
  42. }
  43. @Override
  44. public E poll() throws InterruptedException {
  45. E e;
  46. int c;
  47. headLock.lockInterruptibly();
  48. try {
  49. while (isEmpty()) {
  50. headWaits.await();
  51. }
  52. e = array[head];
  53. if (++head == array.length) {
  54. head = 0;
  55. }
  56. c = size.getAndDecrement();
  57. // b. 队列不空, 但不是从0变化到不空,由此poll线程通知其它poll线程
  58. if (c > 1) {
  59. headWaits.signal();
  60. }
  61. } finally {
  62. headLock.unlock();
  63. }
  64. // a. 从满->不满, 由此poll线程唤醒等待的offer线程
  65. if (c == array.length) {
  66. tailLock.lock();
  67. try {
  68. tailWaits.signal();
  69. } finally {
  70. tailLock.unlock();
  71. }
  72. }
  73. return e;
  74. }
  75. private boolean isEmpty() {
  76. return size.get() == 0;
  77. }
  78. private boolean isFull() {
  79. return size.get() == array.length;
  80. }
  81. }

双锁实现的非常精巧,据说作者 Doug Lea 花了一年的时间才完善了此段代码

2.9 堆

以大顶堆为例,相对于之前的优先级队列,增加了堆化等方法

  1. public class MaxHeap {
  2. int[] array;
  3. int size;
  4. public MaxHeap(int capacity) {
  5. this.array = new int[capacity];
  6. }
  7. /**
  8. * 获取堆顶元素
  9. *
  10. * @return 堆顶元素
  11. */
  12. public int peek() {
  13. return array[0];
  14. }
  15. /**
  16. * 删除堆顶元素
  17. *
  18. * @return 堆顶元素
  19. */
  20. public int poll() {
  21. int top = array[0];
  22. swap(0, size - 1);
  23. size--;
  24. down(0);
  25. return top;
  26. }
  27. /**
  28. * 删除指定索引处元素
  29. *
  30. * @param index 索引
  31. * @return 被删除元素
  32. */
  33. public int poll(int index) {
  34. int deleted = array[index];
  35. swap(index, size - 1);
  36. size--;
  37. down(index);
  38. return deleted;
  39. }
  40. /**
  41. * 替换堆顶元素
  42. * @param replaced 新元素
  43. */
  44. public void replace(int replaced) {
  45. array[0] = replaced;
  46. down(0);
  47. }
  48. /**
  49. * 堆的尾部添加元素
  50. *
  51. * @param offered 新元素
  52. * @return 是否添加成功
  53. */
  54. public boolean offer(int offered) {
  55. if (size == array.length) {
  56. return false;
  57. }
  58. up(offered);
  59. size++;
  60. return true;
  61. }
  62. // 将 offered 元素上浮: 直至 offered 小于父元素或到堆顶
  63. private void up(int offered) {
  64. int child = size;
  65. while (child > 0) {
  66. int parent = (child - 1) / 2;
  67. if (offered > array[parent]) {
  68. array[child] = array[parent];
  69. } else {
  70. break;
  71. }
  72. child = parent;
  73. }
  74. array[child] = offered;
  75. }
  76. public MaxHeap(int[] array) {
  77. this.array = array;
  78. this.size = array.length;
  79. heapify();
  80. }
  81. // 建堆
  82. private void heapify() {
  83. // 如何找到最后这个非叶子节点 size / 2 - 1
  84. for (int i = size / 2 - 1; i >= 0; i--) {
  85. down(i);
  86. }
  87. }
  88. // 将 parent 索引处的元素下潜: 与两个孩子较大者交换, 直至没孩子或孩子没它大
  89. private void down(int parent) {
  90. int left = parent * 2 + 1;
  91. int right = left + 1;
  92. int max = parent;
  93. if (left < size && array[left] > array[max]) {
  94. max = left;
  95. }
  96. if (right < size && array[right] > array[max]) {
  97. max = right;
  98. }
  99. if (max != parent) { // 找到了更大的孩子
  100. swap(max, parent);
  101. down(max);
  102. }
  103. }
  104. // 交换两个索引处的元素
  105. private void swap(int i, int j) {
  106. int t = array[i];
  107. array[i] = array[j];
  108. array[j] = t;
  109. }
  110. public static void main(String[] args) {
  111. int[] array = {1, 2, 3, 4, 5, 6, 7};
  112. MaxHeap maxHeap = new MaxHeap(array);
  113. System.out.println(Arrays.toString(maxHeap.array));
  114. }
  115. }

建堆

Floyd 建堆算法作者(也是之前龟兔赛跑判环作者):
数据结构与算法1 - 图358

  1. 找到最后一个非叶子节点
  2. 从后向前,对每个节点执行下潜

一些规律

  • 一棵满二叉树节点个数为 数据结构与算法1 - 图359,如下例中高度 数据结构与算法1 - 图360 节点数是 数据结构与算法1 - 图361
  • 非叶子节点范围为 数据结构与算法1 - 图362

算法时间复杂度分析
数据结构与算法1 - 图363

下面看交换次数的推导:设节点高度为 3

本层节点数 高度 下潜最多交换次数(高度-1)
4567 这层 4 1 0
23这层 2 2 1
1这层 1 3 2

每一层的交换次数为:数据结构与算法1 - 图364,总的交换次数为

数据结构与算法1 - 图365


数据结构与算法1 - 图366)%0A#card=math&code=%5Csum_%7Bi%3D1%7D%5E%7Bh%7D%28%5Cfrac%7B2%5Eh%7D%7B2%5Ei%7D%2A%28i-1%29%29%0A&id=N5jE1)

https://www.wolframalpha.com/ 输入

  1. Sum[\(40)Divide[Power[2,x],Power[2,i]]*\(40)i-1\(41)\(41),{i,1,x}]

推导出
其中 数据结构与算法1 - 图367数据结构与算法1 - 图368,因此有时间复杂度 数据结构与算法1 - 图369#card=math&code=O%28n%29&id=oOrUe)

2.10 二叉树

二叉树是这么一种树状结构:每个节点最多有两个孩子,左孩子和右孩子

重要的二叉树结构

  • 完全二叉树(complete binary tree)是一种二叉树结构,除最后一层以外,每一层都必须填满,填充时要遵从先左后右
  • 平衡二叉树(balance binary tree)是一种二叉树结构,其中每个节点的左右子树高度相差不超过 1

存储

存储方式分为两种

  1. 定义树节点与左、右孩子引用(TreeNode)
  2. 使用数组,前面讲堆时用过,若以 0 作为树的根,索引可以通过如下方式计算
    • 父 = floor((子 - 1) / 2)
    • 左孩子 = 父 * 2 + 1
    • 右孩子 = 父 * 2 + 2

遍历

遍历也分为两种

  1. 广度优先遍历(Breadth-first order):尽可能先访问距离根最近的节点,也称为层序遍历
  2. 深度优先遍历(Depth-first order):对于二叉树,可以进一步分成三种(要深入到叶子节点)
    1. pre-order 前序遍历,对于每一棵子树,先访问该节点,然后是左子树,最后是右子树
    2. in-order 中序遍历,对于每一棵子树,先访问左子树,然后是该节点,最后是右子树
    3. post-order 后序遍历,对于每一棵子树,先访问左子树,然后是右子树,最后是该节点

广度优先

数据结构与算法1 - 图370

本轮开始时队列 本轮访问节点
[1] 1
[2, 3] 2
[3, 4] 3
[4, 5, 6] 4
[5, 6] 5
[6, 7, 8] 6
[7, 8] 7
[8] 8
[]
  1. 初始化,将根节点加入队列
  2. 循环处理队列中每个节点,直至队列为空
  3. 每次循环内处理节点后,将它的孩子节点(即下一层的节点)加入队列

注意

  • 以上用队列来层序遍历是针对 TreeNode 这种方式表示的二叉树
  • 对于数组表现的二叉树,则直接遍历数组即可,自然为层序遍历的顺序

深度优先

数据结构与算法1 - 图371

栈暂存 已处理 前序遍历 中序遍历
[1] 1 ✔️ 左💤 右💤 1
[1, 2] 2✔️ 左💤 右💤
1✔️ 左💤 右💤
2
[1, 2, 4] 4✔️ 左✔️ 右✔️
2✔️ 左💤 右💤
1✔️ 左💤 右💤
4 4
[1, 2] 2✔️ 左✔️ 右✔️
1✔️ 左💤 右💤
2
[1] 1✔️ 左✔️ 右💤 1
[1, 3] 3✔️ 左💤 右💤
1✔️ 左✔️ 右💤
3
[1, 3, 5] 5✔️ 左✔️ 右✔️
3✔️ 左💤 右💤
1✔️ 左✔️ 右💤
5 5
[1, 3] 3✔️ 左✔️ 右💤
1✔️ 左✔️ 右💤
3
[1, 3, 6] 6✔️ 左✔️ 右✔️
3✔️ 左✔️ 右💤
1✔️ 左✔️ 右💤
6 6
[1, 3] 3✔️ 左✔️ 右✔️
1✔️ 左✔️ 右💤
[1] 1✔️ 左✔️ 右✔️
[]

递归实现

  1. /**
  2. * <h3>前序遍历</h3>
  3. * @param node 节点
  4. */
  5. static void preOrder(TreeNode node) {
  6. if (node == null) {
  7. return;
  8. }
  9. System.out.print(node.val + "\t"); // 值
  10. preOrder(node.left); // 左
  11. preOrder(node.right); // 右
  12. }
  13. /**
  14. * <h3>中序遍历</h3>
  15. * @param node 节点
  16. */
  17. static void inOrder(TreeNode node) {
  18. if (node == null) {
  19. return;
  20. }
  21. inOrder(node.left); // 左
  22. System.out.print(node.val + "\t"); // 值
  23. inOrder(node.right); // 右
  24. }
  25. /**
  26. * <h3>后序遍历</h3>
  27. * @param node 节点
  28. */
  29. static void postOrder(TreeNode node) {
  30. if (node == null) {
  31. return;
  32. }
  33. postOrder(node.left); // 左
  34. postOrder(node.right); // 右
  35. System.out.print(node.val + "\t"); // 值
  36. }

非递归实现

前序遍历

  1. LinkedListStack<TreeNode> stack = new LinkedListStack<>();
  2. TreeNode curr = root;
  3. while (!stack.isEmpty() || curr != null) {
  4. if (curr != null) {
  5. stack.push(curr);
  6. System.out.println(curr);
  7. curr = curr.left;
  8. } else {
  9. TreeNode pop = stack.pop();
  10. curr = pop.right;
  11. }
  12. }

中序遍历

  1. LinkedListStack<TreeNode> stack = new LinkedListStack<>();
  2. TreeNode curr = root;
  3. while (!stack.isEmpty() || curr != null) {
  4. if (curr != null) {
  5. stack.push(curr);
  6. curr = curr.left;
  7. } else {
  8. TreeNode pop = stack.pop();
  9. System.out.println(pop);
  10. curr = pop.right;
  11. }
  12. }

后序遍历

  1. LinkedListStack<TreeNode> stack = new LinkedListStack<>();
  2. TreeNode curr = root;
  3. TreeNode pop = null;
  4. while (!stack.isEmpty() || curr != null) {
  5. if (curr != null) {
  6. stack.push(curr);
  7. curr = curr.left;
  8. } else {
  9. TreeNode peek = stack.peek();
  10. if (peek.right == null || peek.right == pop) {
  11. pop = stack.pop();
  12. System.out.println(pop);
  13. } else {
  14. curr = peek.right;
  15. }
  16. }
  17. }

对于后序遍历,向回走时,需要处理完右子树才能 pop 出栈。如何知道右子树处理完成呢?

  • 如果栈顶元素的 数据结构与算法1 - 图372 表示没啥可处理的,可以出栈
  • 如果栈顶元素的 数据结构与算法1 - 图373
    • 那么使用 lastPop 记录最近出栈的节点,即表示从这个节点向回走
    • 如果栈顶元素的 数据结构与算法1 - 图374 此时应当出栈

对于前、中两种遍历,实际以上代码从右子树向回走时,并未走完全程(stack 提前出栈了)后序遍历以上代码是走完全程了

统一写法
下面是一种统一的写法,依据后序遍历修改

  1. LinkedList<TreeNode> stack = new LinkedList<>();
  2. TreeNode curr = root; // 代表当前节点
  3. TreeNode pop = null; // 最近一次弹栈的元素
  4. while (curr != null || !stack.isEmpty()) {
  5. if (curr != null) {
  6. colorPrintln("前: " + curr.val, 31);
  7. stack.push(curr); // 压入栈,为了记住回来的路
  8. curr = curr.left;
  9. } else {
  10. TreeNode peek = stack.peek();
  11. // 右子树可以不处理, 对中序来说, 要在右子树处理之前打印
  12. if (peek.right == null) {
  13. colorPrintln("中: " + peek.val, 36);
  14. pop = stack.pop();
  15. colorPrintln("后: " + pop.val, 34);
  16. }
  17. // 右子树处理完成, 对中序来说, 无需打印
  18. else if (peek.right == pop) {
  19. pop = stack.pop();
  20. colorPrintln("后: " + pop.val, 34);
  21. }
  22. // 右子树待处理, 对中序来说, 要在右子树处理之前打印
  23. else {
  24. colorPrintln("中: " + peek.val, 36);
  25. curr = peek.right;
  26. }
  27. }
  28. }
  29. public static void colorPrintln(String origin, int color) {
  30. System.out.printf("\033[%dm%s\033[0m%n", color, origin);
  31. }

一张图演示三种遍历
数据结构与算法1 - 图375

  • 红色:前序遍历顺序
  • 绿色:中序遍历顺序
  • 蓝色:后续遍历顺序

三. 练习

3.1 时间复杂度

用函数 数据结构与算法1 - 图376#card=math&code=f%28n%29&id=wKKJ7) 表示算法效率与数据规模的关系,假设每次解决问题需要 1 微秒(数据结构与算法1 - 图377 秒),进行估算:

  1. 如果 数据结构与算法1 - 图378%20%3D%20n%5E2#card=math&code=f%28n%29%20%3D%20n%5E2&id=I10Fq) 那么 1 秒能解决多少次问题?1 天呢?
  2. 如果 数据结构与算法1 - 图379%20%3D%20log_2(n)#card=math&code=f%28n%29%20%3D%20log_2%28n%29&id=FfhvZ) 那么 1 秒能解决多少次问题?1 天呢?
  3. 如果 数据结构与算法1 - 图380%20%3D%20n!#card=math&code=f%28n%29%20%3D%20n%21&id=p6XyD) 那么 1 秒能解决多少次问题?1 天呢?

参考解答

  1. 1秒 数据结构与算法1 - 图381 次,1 天 数据结构与算法1 - 图382
  2. 1秒 $2^{1,000,000} $ 次,一天 数据结构与算法1 - 图383
  3. 推算如下
    • 数据结构与算法1 - 图384 1秒能解决 数据结构与算法1 - 图385 次,因此次数为 9 次
    • 数据结构与算法1 - 图386,一天能解决 数据结构与算法1 - 图387 次,因此次数为 13 次

3.2 二分查找

E01. 二分查找-力扣 704 题

要点:减而治之,可以用递归或非递归实现
给定一个 n 个元素有序的(升序)整型数组 nums 和一个目标值 target ,写一个函数搜索 nums 中的 target,如果目标值存在返回下标,否则返回 -1
例如

  1. 输入: nums = [-1,0,3,5,9,12], target = 9
  2. 输出: 4
  3. 解释: 9 出现在 nums 中并且下标为 4
  4. 输入: nums = [-1,0,3,5,9,12], target = 2
  5. 输出: -1
  6. 解释: 2 不存在 nums 中因此返回 -1

参考答案:略,可以用讲过的任意一种二分求解

E02. 搜索插入位置-力扣 35 题

要点:理解谁代表插入位置
给定一个排序数组和一个目标值

  • 在数组中找到目标值,并返回其索引
  • 如果目标值不存在于数组中,返回它将会被按顺序插入的位置

例如

  1. 输入: nums = [1,3,5,6], target = 5
  2. 输出: 2
  3. 输入: nums = [1,3,5,6], target = 2
  4. 输出: 1
  5. 输入: nums = [1,3,5,6], target = 7
  6. 输出: 4

参考答案1:用二分查找基础版代码改写,基础版中,找到返回 m,没找到 i 代表插入点,因此有

  1. public int searchInsert(int[] a, int target) {
  2. int i = 0, j = a.length - 1;
  3. while (i <= j) {
  4. int m = (i + j) >>> 1;
  5. if (target < a[m]) {
  6. j = m - 1;
  7. } else if (a[m] < target) {
  8. i = m + 1;
  9. } else {
  10. return m;
  11. }
  12. }
  13. return i; // 原始 return -1
  14. }

参考答案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) {
  2. int i = 0, j = a.length;
  3. while (1 < j - i) {
  4. int m = (i + j) >>> 1;
  5. if (target < a[m]) {
  6. j = m;
  7. } else {
  8. i = m;
  9. }
  10. }
  11. return (target <= a[i]) ? i : i + 1;
  12. // 原始 (target == a[i]) ? i : -1;
  13. }

参考答案3:用 leftmost 版本解,返回值即为插入位置(并能处理元素重复的情况)

  1. public int searchInsert(int[] a, int target) {
  2. int i = 0, j = a.length - 1;
  3. while(i <= j) {
  4. int m = (i + j) >>> 1;
  5. if(target <= a[m]) {
  6. j = m - 1;
  7. } else {
  8. i = m + 1;
  9. }
  10. }
  11. return i;
  12. }

E03. 搜索开始结束位置-力扣 34 题

给你一个按照非递减顺序排列的整数数组 nums,和一个目标值 target。请你找出给定目标值在数组中的开始位置和结束位置。

如果数组中不存在目标值 target,返回 [-1, -1]。

你必须设计并实现时间复杂度为 O(log n) 的算法解决此问题

例如

  1. 输入:nums = [5,7,7,8,8,10], target = 8
  2. 输出:[3,4]
  3. 输入:nums = [5,7,7,8,8,10], target = 6
  4. 输出:[-1,-1]
  5. 输入:nums = [], target = 0
  6. 输出:[-1,-1]

参考答案

  1. public static int left(int[] a, int target) {
  2. int i = 0, j = a.length - 1;
  3. int candidate = -1;
  4. while (i <= j) {
  5. int m = (i + j) >>> 1;
  6. if (target < a[m]) {
  7. j = m - 1;
  8. } else if (a[m] < target) {
  9. i = m + 1;
  10. } else {
  11. candidate = m;
  12. j = m - 1;
  13. }
  14. }
  15. return candidate;
  16. }
  17. public static int right(int[] a, int target) {
  18. int i = 0, j = a.length - 1;
  19. int candidate = -1;
  20. while (i <= j) {
  21. int m = (i + j) >>> 1;
  22. if (target < a[m]) {
  23. j = m - 1;
  24. } else if (a[m] < target) {
  25. i = m + 1;
  26. } else {
  27. candidate = m;
  28. i = m + 1;
  29. }
  30. }
  31. return candidate;
  32. }
  33. public static int[] searchRange(int[] nums, int target) {
  34. int x = left(nums, target);
  35. if(x == -1) {
  36. return new int[] {-1, -1};
  37. } else {
  38. return new int[] {x, right(nums, target)};
  39. }
  40. }

3.3 递归 - single recursion

E03. 二分查找

  1. public static int binarySearch(int[] a, int target) {
  2. return recursion(a, target, 0, a.length - 1);
  3. }
  4. public static int recursion(int[] a, int target, int i, int j) {
  5. if (i > j) {
  6. return -1;
  7. }
  8. int m = (i + j) >>> 1;
  9. if (target < a[m]) {
  10. return recursion(a, target, i, m - 1);
  11. } else if (a[m] < target) {
  12. return recursion(a, target, m + 1, j);
  13. } else {
  14. return m;
  15. }
  16. }

E04. 冒泡排序

  1. public static void main(String[] args) {
  2. int[] a = {3, 2, 6, 1, 5, 4, 7};
  3. bubble(a, 0, a.length - 1);
  4. System.out.println(Arrays.toString(a));
  5. }
  6. private static void bubble(int[] a, int low, int high) {
  7. if(low == high) {
  8. return;
  9. }
  10. int j = low;
  11. for (int i = low; i < high; i++) {
  12. if (a[i] > a[i + 1]) {
  13. swap(a, i, i + 1);
  14. j = i;
  15. }
  16. }
  17. bubble(a, low, j);
  18. }
  19. private static void swap(int[] a, int i, int j) {
  20. int t = a[i];
  21. a[i] = a[j];
  22. a[j] = t;
  23. }
  • low 与 high 为未排序范围
  • j 表示的是未排序的边界,下一次递归时的 high
    • 发生交换,意味着有无序情况
    • 最后一次交换(以后没有无序)时,左侧 i 仍是无序,右侧 i+1 已然有序
  • 视频中讲解的是只考虑 high 边界的情况,参考以上代码,理解在 low .. high 范围内的处理方法

E05. 插入排序

  1. public static void main(String[] args) {
  2. int[] a = {3, 2, 6, 1, 5, 7, 4};
  3. insertion(a, 1, a.length - 1);
  4. System.out.println(Arrays.toString(a));
  5. }
  6. private static void insertion(int[] a, int low, int high) {
  7. if (low > high) {
  8. return;
  9. }
  10. int i = low - 1;
  11. int t = a[low];
  12. while (i >= 0 && a[i] > i) {
  13. a[i + 1] = a[i];
  14. i--;
  15. }
  16. if(i + 1 != low) {
  17. a[i + 1] = t;
  18. }
  19. insertion(a, low + 1, high);
  20. }
  • 已排序区域:[0 .. i .. low-1]
  • 未排序区域:[low .. high]
  • 视频中讲解的是只考虑 low 边界的情况,参考以上代码,理解 low-1 .. high 范围内的处理方法
  • 扩展:利用二分查找 leftmost 版本,改进寻找插入位置的代码

E06. 约瑟夫问题[12]

数据结构与算法1 - 图388 个人排成圆圈,从头开始报数,每次数到第 数据结构与算法1 - 图389 个人(数据结构与算法1 - 图390数据结构与算法1 - 图391 开始)杀之,继续从下一个人重复以上过程,求最后活下来的人是谁?

方法1
根据最后的存活者 a 倒推出它在上一轮的索引号

f(n,m) 本轮索引 为了让 a 是这个索引,上一轮应当这样排 规律
f(1,3) 0 x x x a (0 + 3) % 2
f(2,3) 1 x x x 0 a (1 + 3) % 3
f(3,3) 1 x x x 0 a (1 + 3) % 4
f(4,3) 0 x x x a (0 + 3) % 5
f(5,3) 3 x x x 0 1 2 a (3 + 3) % 6
f(6,3) 0 x x x a

方法2
设 n 为总人数,m 为报数次数,解返回的是这些人的索引,从0开始

f(n, m) 规律
f(1, 3) 0
f(2, 3) 0 1 => 1 3%2=1
f(3, 3) 0 1 2 => 0 1 3%3=0
f(4, 3) 0 1 2 3 => 3 0 1 3%4=3
f(5, 3) 0 1 2 3 4 => 3 4 0 1 3%5=3
f(6, 3) 0 1 2 3 4 5 => 3 4 5 0 1 3%6=3

一. 找出等价函数
规律:下次报数的起点为 数据结构与算法1 - 图392

  • 首次出列人的序号是 数据结构与算法1 - 图393,剩下的的 数据结构与算法1 - 图394 个人重新组成约瑟夫环
  • 下次从 数据结构与算法1 - 图395 开始数,序号如下
    • 数据结构与算法1 - 图396,如上例中 数据结构与算法1 - 图397

这个函数称之为 数据结构与算法1 - 图398#card=math&code=g%28n-1%2Cm%29&id=SDuBW),它的最终结果与 数据结构与算法1 - 图399#card=math&code=f%28n%2Cm%29&id=nZSz1) 是相同的。

二. 找到映射函数
现在想办法找到 数据结构与算法1 - 图400#card=math&code=g%28n-1%2Cm%29&id=OpPuI) 与 数据结构与算法1 - 图401#card=math&code=f%28n-1%2C%20m%29&id=mBlMY) 的对应关系,即
数据结构与算法1 - 图402

映射函数为
数据结构与算法1 - 图403%20%3D%20%0A%5Cbegin%7Bcases%7D%0Ax-k%20%26%20x%3D%5Bk..n-1%5D%20%5C%5C%0Ax%2Bn-k%20%26%20x%3D%5B0..k-2%5D%0A%5Cend%7Bcases%7D%0A#card=math&code=mapping%28x%29%20%3D%20%0A%5Cbegin%7Bcases%7D%0Ax-k%20%26%20x%3D%5Bk..n-1%5D%20%5C%5C%0Ax%2Bn-k%20%26%20x%3D%5B0..k-2%5D%0A%5Cend%7Bcases%7D%0A&id=GRaVa)

等价于下面函数
数据结构与算法1 - 图404%20%3D%20(x%20%2B%20n%20-%20k)%5C%25%7Bn%7D%0A#card=math&code=mapping%28x%29%20%3D%20%28x%20%2B%20n%20-%20k%29%5C%25%7Bn%7D%0A&id=bW8tT)

代入测试一下
数据结构与算法1 - 图405%5C%256%20%5Crightarrow%200%20%5C%5C%0A4%20%5Crightarrow%20(4%2B6-3)%5C%256%20%5Crightarrow%201%20%5C%5C%0A5%20%5Crightarrow%20(5%2B6-3)%5C%256%20%5Crightarrow%202%20%5C%5C%0A0%20%5Crightarrow%20(0%2B6-3)%5C%256%20%5Crightarrow%203%20%5C%5C%0A1%20%5Crightarrow%20(1%2B6-3)%5C%256%20%5Crightarrow%204%20%5C%5C%0A#card=math&code=3%20%5Crightarrow%20%283%2B6-3%29%5C%256%20%5Crightarrow%200%20%5C%5C%0A4%20%5Crightarrow%20%284%2B6-3%29%5C%256%20%5Crightarrow%201%20%5C%5C%0A5%20%5Crightarrow%20%285%2B6-3%29%5C%256%20%5Crightarrow%202%20%5C%5C%0A0%20%5Crightarrow%20%280%2B6-3%29%5C%256%20%5Crightarrow%203%20%5C%5C%0A1%20%5Crightarrow%20%281%2B6-3%29%5C%256%20%5Crightarrow%204%20%5C%5C%0A&id=MH9Sw)

综上有
数据结构与算法1 - 图406%20%3D%20mapping(g(n-1%2Cm))%0A#card=math&code=f%28n-1%2Cm%29%20%3D%20mapping%28g%28n-1%2Cm%29%29%0A&id=e6mM4)

三. 求逆映射函数

映射函数是根据 x 计算 y,逆映射函数即根据 y 得到 x
数据结构与算法1 - 图407%20%3D%20(x%20%2B%20k)%5C%25n%0A#card=math&code=mapping%5E%7B-1%7D%28x%29%20%3D%20%28x%20%2B%20k%29%5C%25n%0A&id=ygKCM)

代入测试一下
数据结构与算法1 - 图408%5C%256%20%5Crightarrow%203%20%5C%5C%0A1%20%5Crightarrow%20(1%2B3)%5C%256%20%5Crightarrow%204%20%5C%5C%0A2%20%5Crightarrow%20(2%2B3)%5C%256%20%5Crightarrow%205%20%5C%5C%0A3%20%5Crightarrow%20(3%2B3)%5C%256%20%5Crightarrow%200%20%5C%5C%0A4%20%5Crightarrow%20(4%2B3)%5C%256%20%5Crightarrow%201%20%5C%5C%0A#card=math&code=0%20%5Crightarrow%20%280%2B3%29%5C%256%20%5Crightarrow%203%20%5C%5C%0A1%20%5Crightarrow%20%281%2B3%29%5C%256%20%5Crightarrow%204%20%5C%5C%0A2%20%5Crightarrow%20%282%2B3%29%5C%256%20%5Crightarrow%205%20%5C%5C%0A3%20%5Crightarrow%20%283%2B3%29%5C%256%20%5Crightarrow%200%20%5C%5C%0A4%20%5Crightarrow%20%284%2B3%29%5C%256%20%5Crightarrow%201%20%5C%5C%0A&id=KkC9W)

因此可以求得
数据结构与算法1 - 图409%20%3D%20mapping%5E%7B-1%7D(f(n-1%2Cm))%0A#card=math&code=g%28n-1%2Cm%29%20%3D%20mapping%5E%7B-1%7D%28f%28n-1%2Cm%29%29%0A&id=W5478)

四. 递推式
代入推导
数据结构与算法1 - 图410%20%3D%20%5C%20%26%20g(n-1%2Cm)%20%5C%5C%0A%3D%20%5C%20%26%20mapping%5E%7B-1%7D(f(n-1%2Cm))%20%5C%5C%0A%3D%20%5C%20%26%20(f(n-1%2Cm)%20%2B%20k)%20%5C%25%20n%20%5C%5C%0A%3D%20%5C%20%26%20(f(n-1%2Cm)%20%2B%20m%5C%25n)%20%5C%25%20n%20%5C%5C%0A%3D%20%5C%20%26%20(f(n-1%2Cm)%20%2B%20m)%20%5C%25%20n%20%5C%5C%0A%5Cend%7Baligned%7D%0A#card=math&code=%5Cbegin%7Baligned%7D%0Af%28n%2Cm%29%20%3D%20%5C%20%26%20g%28n-1%2Cm%29%20%5C%5C%0A%3D%20%5C%20%26%20mapping%5E%7B-1%7D%28f%28n-1%2Cm%29%29%20%5C%5C%0A%3D%20%5C%20%26%20%28f%28n-1%2Cm%29%20%2B%20k%29%20%5C%25%20n%20%5C%5C%0A%3D%20%5C%20%26%20%28f%28n-1%2Cm%29%20%2B%20m%5C%25n%29%20%5C%25%20n%20%5C%5C%0A%3D%20%5C%20%26%20%28f%28n-1%2Cm%29%20%2B%20m%29%20%5C%25%20n%20%5C%5C%0A%5Cend%7Baligned%7D%0A&id=WcLK8)

最后一步化简是利用了模运算法则
数据结构与算法1 - 图411%5C%25n%20%3D%20(a%5C%25n%20%2B%20b%5C%25n)%20%5C%25n#card=math&code=%28a%2Bb%29%5C%25n%20%3D%20%28a%5C%25n%20%2B%20b%5C%25n%29%20%5C%25n&id=MsEn1) 例如

  • 数据结构与算法1 - 图412%5C%255%20%3D%202%20%3D%20(6%2B6%5C%255)%5C%255#card=math&code=%286%2B6%29%5C%255%20%3D%202%20%3D%20%286%2B6%5C%255%29%5C%255&id=fBXTs)
  • 数据结构与算法1 - 图413%5C%255%20%3D%201%20%3D%20(6%2B5%5C%255)%5C%255#card=math&code=%286%2B5%29%5C%255%20%3D%201%20%3D%20%286%2B5%5C%255%29%5C%255&id=PHyXt)
  • 数据结构与算法1 - 图414%5C%255%20%3D%200%20%3D%20(6%2B4%5C%255)%5C%255#card=math&code=%286%2B4%29%5C%255%20%3D%200%20%3D%20%286%2B4%5C%255%29%5C%255&id=i38mB)

最终递推式
数据结构与算法1 - 图415%20%3D%20%0A%5Cbegin%7Bcases%7D%0A(f(n-1%2Cm)%20%2B%20m)%20%5C%25%20n%20%26%20n%3E1%5C%5C%0A0%20%26%20n%20%3D%201%0A%5Cend%7Bcases%7D%0A#card=math&code=f%28n%2Cm%29%20%3D%20%0A%5Cbegin%7Bcases%7D%0A%28f%28n-1%2Cm%29%20%2B%20m%29%20%5C%25%20n%20%26%20n%3E1%5C%5C%0A0%20%26%20n%20%3D%201%0A%5Cend%7Bcases%7D%0A&id=VsbNp)

3.4 递归 - multi recursion

E02. 汉诺塔[13]

Tower of Hanoi,是一个源于印度古老传说:大梵天创建世界时做了三根金刚石柱,在一根柱子从下往上按大小顺序摞着 64 片黄金圆盘,大梵天命令婆罗门把圆盘重新摆放在另一根柱子上,并且规定

  • 一次只能移动一个圆盘
  • 小圆盘上不能放大圆盘

下面的动图演示了4片圆盘的移动方法
数据结构与算法1 - 图416

使用程序代码模拟圆盘的移动过程,并估算出时间复杂度

思路

  • 假设每根柱子标号 a,b,c,每个圆盘用 1,2,3 … 表示其大小,圆盘初始在 a,要移动到的目标是 c
  • 如果只有一个圆盘,此时是最小问题,可以直接求解
    • 移动圆盘1 数据结构与算法1 - 图417

数据结构与算法1 - 图418

  • 如果有两个圆盘,那么
    • 圆盘1 数据结构与算法1 - 图419
    • 圆盘2 数据结构与算法1 - 图420
    • 圆盘1 数据结构与算法1 - 图421

数据结构与算法1 - 图422

  • 如果有三个圆盘,那么
    • 圆盘12 数据结构与算法1 - 图423
    • 圆盘3 数据结构与算法1 - 图424
    • 圆盘12 数据结构与算法1 - 图425

数据结构与算法1 - 图426

  • 如果有四个圆盘,那么
    • 圆盘 123 数据结构与算法1 - 图427
    • 圆盘4 数据结构与算法1 - 图428
    • 圆盘 123 数据结构与算法1 - 图429

数据结构与算法1 - 图430

题解

  1. public class E02HanoiTower {
  2. /*
  3. 源 借 目
  4. h(4, a, b, c) -> h(3, a, c, b)
  5. a -> c
  6. h(3, b, a, c)
  7. */
  8. static LinkedList<Integer> a = new LinkedList<>();
  9. static LinkedList<Integer> b = new LinkedList<>();
  10. static LinkedList<Integer> c = new LinkedList<>();
  11. static void init(int n) {
  12. for (int i = n; i >= 1; i--) {
  13. a.add(i);
  14. }
  15. }
  16. static void h(int n, LinkedList<Integer> a,
  17. LinkedList<Integer> b,
  18. LinkedList<Integer> c) {
  19. if (n == 0) {
  20. return;
  21. }
  22. h(n - 1, a, c, b);
  23. c.addLast(a.removeLast());
  24. print();
  25. h(n - 1, b, a, c);
  26. }
  27. private static void print() {
  28. System.out.println("-----------------------");
  29. System.out.println(a);
  30. System.out.println(b);
  31. System.out.println(c);
  32. }
  33. public static void main(String[] args) {
  34. init(3);
  35. print();
  36. h(3, a, b, c);
  37. }
  38. }

E03. 杨辉三角[14]

数据结构与算法1 - 图431

分析
把它斜着看

  1. 1
  2. 1 1
  3. 1 2 1
  4. 1 3 3 1
  5. 1 4 6 4 1
  • 数据结构与算法1 - 图432,列 数据结构与算法1 - 图433,那么 数据结构与算法1 - 图434 的取值应为 数据结构与算法1 - 图435
  • 数据结构与算法1 - 图436数据结构与算法1 - 图437 时,数据结构与算法1 - 图438 取值为 数据结构与算法1 - 图439

题解

  1. public static void print(int n) {
  2. for (int i = 0; i < n; i++) {
  3. if (i < n - 1) {
  4. System.out.printf("%" + 2 * (n - 1 - i) + "s", " ");
  5. }
  6. for (int j = 0; j < i + 1; j++) {
  7. System.out.printf("%-4d", element(i, j));
  8. }
  9. System.out.println();
  10. }
  11. }
  12. public static int element(int i, int j) {
  13. if (j == 0 || i == j) {
  14. return 1;
  15. }
  16. return element(i - 1, j - 1) + element(i - 1, j);
  17. }

优化1
是 multiple recursion,因此很多递归调用是重复的,例如

  • recursion(3, 1) 分解为
    • recursion(2, 0) + recursion(2, 1)
  • 而 recursion(3, 2) 分解为
    • recursion(2, 1) + recursion(2, 2)

这里 recursion(2, 1) 就重复调用了,事实上它会重复很多次,可以用 static AtomicInteger counter = new AtomicInteger(0) 来查看递归函数的调用总次数

事实上,可以用 memoization 来进行优化:

  1. public static void print1(int n) {
  2. int[][] triangle = new int[n][];
  3. for (int i = 0; i < n; i++) {
  4. // 打印空格
  5. triangle[i] = new int[i + 1];
  6. for (int j = 0; j <= i; j++) {
  7. System.out.printf("%-4d", element1(triangle, i, j));
  8. }
  9. System.out.println();
  10. }
  11. }
  12. public static int element1(int[][] triangle, int i, int j) {
  13. if (triangle[i][j] > 0) {
  14. return triangle[i][j];
  15. }
  16. if (j == 0 || i == j) {
  17. triangle[i][j] = 1;
  18. return triangle[i][j];
  19. }
  20. triangle[i][j] = element1(triangle, i - 1, j - 1) + element1(triangle, i - 1, j);
  21. return triangle[i][j];
  22. }
  • 将数组作为递归函数内可以访问的遍历,如果 数据结构与算法1 - 图440 已经有值,说明该元素已经被之前的递归函数计算过,就不必重复计算了

优化2

  1. public static void print2(int n) {
  2. int[] row = new int[n];
  3. for (int i = 0; i < n; i++) {
  4. // 打印空格
  5. createRow(row, i);
  6. for (int j = 0; j <= i; j++) {
  7. System.out.printf("%-4d", row[j]);
  8. }
  9. System.out.println();
  10. }
  11. }
  12. private static void createRow(int[] row, int i) {
  13. if (i == 0) {
  14. row[0] = 1;
  15. return;
  16. }
  17. for (int j = i; j > 0; j--) {
  18. row[j] = row[j - 1] + row[j];
  19. }
  20. }

注意:还可以通过每一行的前一项计算出下一项,不必借助上一行,这与杨辉三角的另一个特性有关,暂不展开了

力扣对应题目,但递归不适合在力扣刷高分,因此只列出相关题目,不做刷题讲解了

3.5 链表

E01. 反转单向链表-力扣 206 题

对应力扣题目 206. 反转链表 - 力扣(LeetCode)

  1. 输入:head = [1,2,3,4,5]
  2. 输出:[5,4,3,2,1]
  3. 输入:[1,2]
  4. 输出:[2,1]
  5. 输入:[]
  6. 输出:[]

方法1
构造一个新链表,从旧链表依次拿到每个节点,创建新节点添加至新链表头部,完成后新链表即是倒序的

  1. public ListNode reverseList(ListNode o1) {
  2. ListNode n1 = null;
  3. ListNode p = o1;
  4. while (p != null) {
  5. n1 = new ListNode(p.val, n1);
  6. p = p.next;
  7. }
  8. return n1;
  9. }

评价:简单直白,就是得新创建节点对象

方法2
与方法1 类似,构造一个新链表,从旧链表头部移除节点,添加到新链表头部,完成后新链表即是倒序的,区别在于原题目未提供节点外层的容器类,这里提供一个,另外一个区别是并不去构造新节点

  1. static class List {
  2. ListNode head;
  3. public List(ListNode head) {
  4. this.head = head;
  5. }
  6. public ListNode removeFirst(){
  7. ListNode first = head;
  8. if (first != null) {
  9. head = first.next;
  10. }
  11. return first;
  12. }
  13. public void addFirst(ListNode first) {
  14. first.next = head;
  15. head = first;
  16. }
  17. }

代码

  1. public ListNode reverseList(ListNode head) {
  2. List list1 = new List(head);
  3. List list2 = new List(null);
  4. ListNode first;
  5. while ((first = list1.removeFirst()) != null) {
  6. list2.addFirst(first);
  7. }
  8. return list2.head;
  9. }

评价:更加面向对象,如果实际写代码而非刷题,更多会这么做

方法3
递归,在时让 数据结构与算法1 - 图441数据结构与算法1 - 图442
首先,写一个递归方法,返回值用来拿到最后一个节点

  1. public ListNode reverseList(ListNode p) {
  2. if (p == null || p.next == null) { // 不足两个节点
  3. return p; // 最后一个节点
  4. }
  5. ListNode last = reverseList(p.next);
  6. return last;
  7. }
  • 注意1:递归终止条件是 curr.next == null,目的是到最后一个节点就结束递归,与之前递归遍历不一样
  • 注意2:需要考虑空链表即 p == null 的情况

可以先测试一下

  1. ListNode o5 = new ListNode(5, null);
  2. ListNode o4 = new ListNode(4, o5);
  3. ListNode o3 = new ListNode(3, o4);
  4. ListNode o2 = new ListNode(2, o3);
  5. ListNode o1 = new ListNode(1, o2);
  6. ListNode n1 = new E01Leetcode206().reverseList(o1);
  7. System.out.println(n1);

会打印

  1. [5]

下面为伪码调用过程,假设节点分别是 数据结构与算法1 - 图443,先忽略返回值

  1. reverseList(ListNode p = 1) {
  2. reverseList(ListNode p = 2) {
  3. reverseList(ListNode p = 3) {
  4. reverseList(ListNode p = 4) {
  5. reverseList(ListNode p = 5) {
  6. if (p == null || p.next == null) {
  7. return p; // 返回5
  8. }
  9. }
  10. // 此时p是4, p.next是5
  11. }
  12. // 此时p是3, p.next是4
  13. }
  14. // 此时p是2, p.next是3
  15. }
  16. // 此时p是1, p.next是2
  17. }

接下来,从 p = 4 开始,要让 数据结构与算法1 - 图444数据结构与算法1 - 图445

  1. reverseList(ListNode p = 1) {
  2. reverseList(ListNode p = 2) {
  3. reverseList(ListNode p = 3) {
  4. reverseList(ListNode p = 4) {
  5. reverseList(ListNode p = 5) {
  6. if (p == null || p.next == null) {
  7. return p; // 返回5
  8. }
  9. }
  10. // 此时p是4, p.next是5, 要让5指向4,代码写成 p.next.next=p
  11. // 还要注意4要指向 null, 否则就死链了
  12. }
  13. // 此时p是3, p.next是4
  14. }
  15. // 此时p是2, p.next是3
  16. }
  17. // 此时p是1, p.next是2
  18. }

最终代码为:

  1. public ListNode reverseList(ListNode p) {
  2. if (p == null || p.next == null) { // 不足两个节点
  3. return p; // 最后一个节点
  4. }
  5. ListNode last = reverseList(p.next);
  6. p.next.next = p;
  7. p.next = null;
  8. return last;
  9. }

Q:为啥不能在的过程中倒序?
A:比如

  • $ 1 \rightarrow 2 \rightarrow 3 $ 如果递的过程中让 数据结构与算法1 - 图446 那么此时 数据结构与算法1 - 图447 就被覆盖,不知道接下来递给谁
  • 而归的时候让 数据结构与算法1 - 图448 不会影响上一层的 数据结构与算法1 - 图449

评价:单向链表没有 prev 指针,但利用递归的特性【记住了】链表每次调用时相邻两个节点是谁

方法4
从链表每次拿到第二个节点,将其从链表断开,插入头部,直至它为 null 结束

  1. 设置指针 o1(旧头)、n1(新头)、o2(旧老二),分别指向第一,第一,第二节点

数据结构与算法1 - 图450

  1. 将 o2 节点从链表断开,即 o1 节点指向第三节点

$ \frac{n1 \ o1}{1} \rightarrow 3 \rightarrow 4 \rightarrow 5 \rightarrow null$ ,数据结构与算法1 - 图451

  1. o2 节点链入链表头部,即

数据结构与算法1 - 图452

  1. n1 指向 o2

数据结构与算法1 - 图453

  1. o2 指向 o1 的下一个节点,即

数据结构与算法1 - 图454

  1. 重复以上 数据结构与算法1 - 图455 步,直到 o2 指向 null
  2. 还应当考虑边界条件,即链表中不满两个元素时,无需走以上逻辑

参考答案

  1. public ListNode reverseList(ListNode o1) {
  2. if (o1 == null || o1.next == null) { // 不足两个节点
  3. return o1;
  4. }
  5. ListNode o2 = o1.next;
  6. ListNode n1 = o1;
  7. while (o2 != null) {
  8. o1.next = o2.next;
  9. o2.next = n1;
  10. n1 = o2;
  11. o2 = o1.next;
  12. }
  13. return n1;
  14. }

方法5

要点:把链表分成两部分,思路就是不断从链表2的头,往链表1的头搬移

  1. n1 指向 null,代表新链表一开始没有元素,o1 指向原链表的首节点

数据结构与算法1 - 图456数据结构与算法1 - 图457

  1. 开始循环,o2 指向原链表次节点

数据结构与算法1 - 图458数据结构与算法1 - 图459

  1. 搬移

数据结构与算法1 - 图460数据结构与算法1 - 图461

  1. 指针复位

数据结构与算法1 - 图462数据结构与算法1 - 图463

  1. 重复 数据结构与算法1 - 图464
  2. 当 o1 = null 时退出循环

参考答案

  1. public ListNode reverseList(ListNode o1) {
  2. if (o1 == null || o1.next == null) {
  3. return o1;
  4. }
  5. ListNode n1 = null;
  6. while (o1 != null) {
  7. ListNode o2 = o1.next;
  8. o1.next = n1;
  9. n1 = o1;
  10. o1 = o2;
  11. }
  12. return n1;
  13. }

评价:本质上与方法2 相同,只是方法2更为面向对象

E02. 根据值删除节点-力扣 203 题

例如

  1. 输入:head = [1,2,6,3,6], val = 6
  2. 输出:[1,2,3]
  3. 输入:head = [], val = 1
  4. 输出:[]
  5. 输入:head = [7,7,7,7], val = 7
  6. 输出:[]

方法1
图中 s 代表 sentinel 哨兵(如果不加哨兵,则删除第一个节点要特殊处理),例如要删除 6

  1. p1 p2
  2. s -> 1 -> 2 -> 6 -> 3 -> 6 -> null
  • 如果 p2 不等于目标,则 p1,p2 不断后移

    1. p1 p2
    2. s -> 1 -> 2 -> 6 -> 3 -> 6 -> null
    3. p1 p2
    4. s -> 1 -> 2 -> 6 -> 3 -> 6 -> null
  • p2 == 6,删除它,注意 p1 此时保持不变,p2 后移

    1. p1 p2
    2. s -> 1 -> 2 -> 3 -> 6 -> null
  • p2 不等于目标,则 p1,p2 不断后移

    1. p1 p2
    2. s -> 1 -> 2 -> 3 -> 6 -> null
  • p2 == 6,删除它,注意 p1 此时保持不变,p2 后移

    1. p1 p2
    2. s -> 1 -> 2 -> 3 -> null
  • p2 == null 退出循环

最后代码

  1. public ListNode removeElements(ListNode head, int val) {
  2. ListNode sentinel = new ListNode(-1, head);
  3. ListNode p1 = sentinel;
  4. ListNode p2;
  5. while ((p2 = p1.next) != null) {
  6. if (p2.val == val) {
  7. p1.next = p2.next;
  8. } else {
  9. p1 = p1.next;
  10. }
  11. }
  12. return sentinel.next;
  13. }

方法2
思路,递归函数负责返回:从当前节点(我)开始,完成删除的子链表

  1. 若我与 v 相等,应该返回下一个节点递归结果
  2. 若我与 v 不等,应该返回我,但我的 next 应该更新(让我能带上后续删过的子链表)
    1. removeElements(ListNode p=1, int v=6){
    2. 1.next=removeElements(ListNode p=2, int v=6){
    3. 2.next=removeElements(ListNode p=6, int v=6){
    4. removeElements(ListNode p=3, int v=6){
    5. 3.next=removeElements(ListNode p=6, int v=6){
    6. removeElements(ListNode p=null, int v=6){
    7. // 没有节点,返回
    8. return null
    9. }
    10. }
    11. return 3
    12. }
    13. }
    14. return 2
    15. }
    16. return 1
    17. }

代码

  1. public ListNode removeElements(ListNode head, int val) {
  2. if (head == null) {
  3. return null;
  4. }
  5. if (head.val == val) {
  6. return removeElements(head.next, val);
  7. } else {
  8. head.next = removeElements(head.next, val);
  9. return head;
  10. }
  11. }

E03. 删除倒数节点-力扣 19 题

例如

  1. 输入:head = [1,2,3,4,5], n = 2
  2. 输出:[1,2,3,5]
  3. 输入:head = [1], n = 1
  4. 输出:[]
  5. 输入:head = [1,2], n = 1
  6. 输出:[1]

另外题目提示

  • 链表至少一个节点
  • n 只会在合理范围

方法1
思路,写一个递归函数,用来返回下一个节点的倒数序号

  1. recursion(ListNode p=1, int n=2) {
  2. recursion(ListNode p=2, int n=2) {
  3. recursion(ListNode p=3, int n=2) {
  4. recursion(ListNode p=4, int n=2) {
  5. recursion(ListNode p=5, int n=2) {
  6. recursion(ListNode p=null, int n=2) {
  7. return 0; // 最内层序号0
  8. }
  9. return 1; // 上一次返回值+1
  10. }
  11. return 2;
  12. }
  13. if(返回值 == n == 2) {
  14. // 删除 next
  15. }
  16. return 3;
  17. }
  18. return 4;
  19. }
  20. return 5;
  21. }

但上述代码有一个问题,就是若删除的是第一个节点,它没有上一个节点,因此可以加一个哨兵来解决

代码

  1. public ListNode removeNthFromEnd(ListNode head, int n) {
  2. ListNode sentinel = new ListNode(-1, head);
  3. recursion(sentinel, n);
  4. return sentinel.next;
  5. }
  6. public int recursion(ListNode p, int n) {
  7. if (p == null) {
  8. return 0;
  9. }
  10. int nth = recursion(p.next, n);
  11. if (nth == n) {
  12. p.next = p.next.next;
  13. }
  14. return nth + 1;
  15. }

Q:p.next.next 不怕空指针吗?
A:

  • p 是待删除节点的上一个节点,如果能递归回到 p,那么 p.next 肯定有值,不会是 null
  • 且题目说明了 n >=1,不会因为 nth == 0 而让 p.next 指向最后的 null

方法2
快慢指针,p1 指向待删节点的上一个,p2 先走 n + 1 步

  1. i=0
  2. p2
  3. s -> 1 -> 2 -> 3 -> 4 -> 5 -> null
  4. i=1
  5. p2
  6. s -> 1 -> 2 -> 3 -> 4 -> 5 -> null
  7. i=2
  8. p2
  9. s -> 1 -> 2 -> 3 -> 4 -> 5 -> null
  10. i=3 从此开始 p1 p2 依次向右平移, 直到 p2 移动到末尾
  11. p1 p2
  12. s -> 1 -> 2 -> 3 -> 4 -> 5 -> null
  13. p1 p2
  14. s -> 1 -> 2 -> 3 -> 4 -> 5 -> null

代码

  1. public ListNode removeNthFromEnd(ListNode head, int n) {
  2. ListNode s = new ListNode(-1, head);
  3. ListNode p1 = s;
  4. ListNode p2 = s;
  5. for (int i = 0; i < n + 1; i++) {
  6. p2 = p2.next;
  7. }
  8. while (p2 != null) {
  9. p1 = p1.next;
  10. p2 = p2.next;
  11. }
  12. p1.next = p1.next.next;
  13. return s.next;
  14. }

方法3

  1. public ListNode removeNthFromEnd(ListNode head, int n) {
  2. Composite c = recursion(head, n);
  3. return c.node;
  4. }
  5. static class Composite {
  6. ListNode node;
  7. int nth;
  8. public Composite(ListNode node, int nth) {
  9. this.node = node;
  10. this.nth = nth;
  11. }
  12. }
  13. public Composite recursion(ListNode p, int n) {
  14. if (p == null) {
  15. return new Composite(null, 1);
  16. }
  17. Composite c = recursion(p.next, n);
  18. if (c.nth != n) {
  19. p.next = c.node;
  20. c.node = p;
  21. }
  22. c.nth +=1;
  23. return c;
  24. }

E04. 有序链表去重-力扣 83 题

例如

  1. 输入:head = [1,1,2]
  2. 输出:[1,2]
  3. 输入:head = [1,1,2,3,3]
  4. 输出:[1,2,3]

注意:重复元素保留一个

方法1

  1. p1 p2
  2. 1 -> 1 -> 2 -> 3 -> 3 -> null
  • p1.val == p2.val 那么删除 p2,注意 p1 此时保持不变

    1. p1 p2
    2. 1 -> 2 -> 3 -> 3 -> null
  • p1.val != p2.val 那么 p1,p2 向后移动

    1. p1 p2
    2. 1 -> 2 -> 3 -> 3 -> null
    3. p1 p2
    4. 1 -> 2 -> 3 -> 3 -> null
  • p1.val == p2.val 那么删除 p2

    1. p1 p2
    2. 1 -> 2 -> 3 -> null
  • 当 p2 == null 退出循环

代码

  1. public ListNode deleteDuplicates(ListNode head) {
  2. // 链表节点 < 2
  3. if (head == null || head.next == null) {
  4. return head;
  5. }
  6. // 链表节点 >= 2
  7. ListNode p1 = head;
  8. ListNode p2;
  9. while ((p2 = p1.next) != null) {
  10. if (p1.val == p2.val) {
  11. p1.next = p2.next;
  12. } else {
  13. p1 = p1.next;
  14. }
  15. }
  16. return head;
  17. }

方法2
递归函数负责返回:从当前节点(我)开始,完成去重的链表

  1. 若我与 next 重复,返回 next
  2. 若我与 next 不重复,返回我,但 next 应当更新
    1. deleteDuplicates(ListNode p=1) {
    2. deleteDuplicates(ListNode p=1) {
    3. 1.next=deleteDuplicates(ListNode p=2) {
    4. 2.next=deleteDuplicates(ListNode p=3) {
    5. deleteDuplicates(ListNode p=3) {
    6. // 只剩一个节点,返回
    7. return 3
    8. }
    9. }
    10. return 2
    11. }
    12. return 1
    13. }
    14. }

代码

  1. public ListNode deleteDuplicates(ListNode p) {
  2. if (p == null || p.next == null) {
  3. return p;
  4. }
  5. if(p.val == p.next.val) {
  6. return deleteDuplicates(p.next);
  7. } else {
  8. p.next = deleteDuplicates(p.next);
  9. return p;
  10. }
  11. }

E05. 有序链表去重-力扣 82 题

例如

  1. 输入:head = [1,2,3,3,4,4,5]
  2. 输出:[1,2,5]
  3. 输入:head = [1,1,1,2,3]
  4. 输出:[2,3]

注意:重复元素一个不留

方法1
递归函数负责返回:从当前节点(我)开始,完成去重的链表

  1. 若我与 next 重复,一直找到下一个不重复的节点,以它的返回结果为准
  2. 若我与 next 不重复,返回我,同时更新 next
    1. deleteDuplicates(ListNode p = 1) {
    2. // 找下个不重复的
    3. deleteDuplicates(ListNode p = 1) {
    4. deleteDuplicates(ListNode p = 1) {
    5. deleteDuplicates(ListNode p = 2) {
    6. 2.next=deleteDuplicates(ListNode p = 3) {
    7. // 只剩一个节点,返回
    8. return 3
    9. }
    10. return 2
    11. }
    12. }
    13. }
    14. }

代码

  1. public ListNode deleteDuplicates(ListNode p) {
  2. if (p == null || p.next == null) {
  3. return p;
  4. }
  5. if (p.val == p.next.val) {
  6. ListNode x = p.next.next;
  7. while (x != null && x.val == p.val) {
  8. x = x.next;
  9. }
  10. return deleteDuplicates(x);
  11. } else {
  12. p.next = deleteDuplicates(p.next);
  13. return p;
  14. }
  15. }

方法2
p1 是待删除的上一个节点,每次循环对比 p2、p3 的值

  • 如果 p2 与 p3 的值重复,那么 p3 继续后移,直到找到与 p2 不重复的节点,p1 指向 p3 完成删除
  • 如果 p2 与 p3 的值不重复,p1,p2,p3 向后平移一位,继续上面的操作
  • p2 或 p3 为 null 退出循环
    • p2 为 null 的情况,比如链表为 1 1 1 null ``` p1 p2 p3 s, 1, 1, 1, 2, 3, null

p1 p2 p3 s, 1, 1, 1, 2, 3, null

p1 p2 p3 s, 1, 1, 1, 2, 3, null

p1 p3 s, 2, 3, null

p1 p2 p3 s, 2, 3, null

p1 p2 p3 s, 2, 3, null

  1. 代码
  2. ```java
  3. public ListNode deleteDuplicates(ListNode head) {
  4. if (head == null || head.next == null) {
  5. return head;
  6. }
  7. ListNode s = new ListNode(-1, head);
  8. ListNode p1 = s;
  9. ListNode p2;
  10. ListNode p3;
  11. while ((p2 = p1.next) != null && (p3 = p2.next) != null) {
  12. if (p2.val == p3.val) {
  13. while ((p3 = p3.next) != null
  14. && p3.val == p2.val) {
  15. }
  16. p1.next = p3;
  17. } else {
  18. p1 = p1.next;
  19. }
  20. }
  21. return s.next;
  22. }

E06. 合并有序链表-力扣 21 题

  1. 输入:l1 = [1,2,4], l2 = [1,3,4]
  2. 输出:[1,1,2,3,4,4]
  3. 输入:l1 = [], l2 = []
  4. 输出:[]
  5. 输入:l1 = [], l2 = [0]
  6. 输出:[0]

方法1

  • 谁小,把谁链给 p,p 和小的都向后平移一位
  • 当 p1、p2 有一个为 null,退出循环,把不为 null 的链给 p ``` p1 1 3 8 9 null

p2 2 4 null

p
s null

  1. 代码
  2. ```java
  3. public ListNode mergeTwoLists(ListNode p1, ListNode p2) {
  4. ListNode s = new ListNode(-1, null);
  5. ListNode p = s;
  6. while (p1 != null && p2 != null) {
  7. if (p1.val < p2.val) {
  8. p.next = p1;
  9. p1 = p1.next;
  10. } else {
  11. p.next = p2;
  12. p2 = p2.next;
  13. }
  14. p = p.next;
  15. }
  16. if (p1 != null) {
  17. p.next = p1;
  18. }
  19. if (p2 != null) {
  20. p.next = p2;
  21. }
  22. return s.next;
  23. }
  • 可以自行验证中后两种情况

方法2
递归函数应该返回

  • 更小的那个链表节点,并把它剩余节点与另一个链表再次递归
  • 返回之前,更新此节点的 next
    1. mergeTwoLists(p1=[1,3,8,9], p2=[2,4]) {
    2. 1.next=mergeTwoLists(p1=[3,8,9], p2=[2,4]) {
    3. 2.next=mergeTwoLists(p1=[3,8,9], p2=[4]) {
    4. 3.next=mergeTwoLists(p1=[8,9], p2=[4]) {
    5. 4.next=mergeTwoLists(p1=[8,9], p2=null) {
    6. return [8,9]
    7. }
    8. return 4
    9. }
    10. return 3
    11. }
    12. return 2
    13. }
    14. return 1
    15. }

E07. 合并多个有序链表-力扣 23 题

  1. 输入:lists = [[1,4,5],[1,3,4],[2,6]]
  2. 输出:[1,1,2,3,4,4,5,6]
  3. 解释:链表数组如下:
  4. [
  5. 1->4->5,
  6. 1->3->4,
  7. 2->6
  8. ]
  9. 将它们合并到一个有序链表中得到。
  10. 1->1->2->3->4->4->5->6

方法1
递归

  1. public ListNode mergeKLists(ListNode[] lists) {
  2. if (lists.length == 0) {
  3. return null;
  4. }
  5. return merge(lists, 0, lists.length - 1);
  6. }
  7. public ListNode split(ListNode[] lists, int i, int j) {
  8. System.out.println(i + " " + j);
  9. if (j == i) {
  10. return lists[i];
  11. }
  12. int m = (i + j) >>> 1;
  13. return mergeTwoLists(
  14. split(lists, i, m),
  15. split(lists, m + 1, j)
  16. );
  17. }

还可以用优先级队列求解,这个放在后面讲

E08. 查找链表中间节点-力扣 876 题

例如

  1. 输入:[1,2,3,4,5]
  2. 输出:此列表中的结点 3 (序列化形式:[3,4,5])
  3. 输入:[1,2,3,4,5,6]
  4. 输出:此列表中的结点 4 (序列化形式:[4,5,6])
  • 偶数节点时,中间点是靠右的那个

解法:快慢指针,快指针一次走两步,慢指针一次走一步,当快指针到链表结尾时,慢指针恰好走到链表的一半

  1. public ListNode middleNode(ListNode head) {
  2. ListNode p1 = head; // 慢指针,中间点
  3. ListNode p2 = head; // 快指针
  4. while (p2 != null && p2.next != null) {
  5. p1 = p1.next;
  6. p2 = p2.next;
  7. p2 = p2.next;
  8. }
  9. return p1;
  10. }

E09. 回文链表-力扣 234 题

所谓回文指正着读、反着读,结果一样,例如

  1. [1,2,2,1]
  2. [1,2,3,2,1]

它们都是回文链表,不是回文的例子

  1. [1,2,3,1] --反过来--> [1,3,2,1]

解法

  1. /*
  2. 步骤1. 找中间点
  3. 步骤2. 中间点后半个链表反转
  4. 步骤3. 反转后链表与原链表逐一比较
  5. */
  6. public boolean isPalindrome(ListNode head) {
  7. ListNode middle = middle(head);
  8. ListNode newHead = reverse(middle);
  9. while (newHead != null) {
  10. if (newHead.val != head.val) {
  11. return false;
  12. }
  13. newHead = newHead.next;
  14. head = head.next;
  15. }
  16. return true;
  17. }
  18. private ListNode reverse(ListNode o1) {
  19. ListNode n1 = null;
  20. while (o1 != null) {
  21. ListNode o2 = o1.next;
  22. o1.next = n1;
  23. n1 = o1;
  24. o1 = o2;
  25. }
  26. return n1;
  27. }
  28. private ListNode middle(ListNode head) {
  29. ListNode p1 = head; // 慢
  30. ListNode p2 = head; // 快
  31. while (p2 != null && p2.next != null) {
  32. p1 = p1.next;
  33. p2 = p2.next.next;
  34. }
  35. return p1;
  36. }

优化后解法

  1. public boolean isPalindrome(ListNode h1) {
  2. if (h1 == null || h1.next == null) {
  3. return true;
  4. }
  5. ListNode p1 = h1; // 慢指针,中间点
  6. ListNode p2 = h1; // 快指针
  7. ListNode n1 = null; // 新头
  8. ListNode o1 = h1; // 旧头
  9. // 快慢指针找中间点
  10. while (p2 != null && p2.next != null) {
  11. p1 = p1.next;
  12. p2 = p2.next.next;
  13. // 反转前半部分
  14. o1.next = n1;
  15. n1 = o1;
  16. o1 = p1;
  17. }
  18. if (p2 != null) { // 节点数为奇数
  19. p1 = p1.next;
  20. }
  21. // 同步比较新头和后半部分
  22. while (n1 != null) {
  23. if (n1.val != p1.val) {
  24. return false;
  25. }
  26. p1 = p1.next;
  27. n1 = n1.next;
  28. }
  29. return true;
  30. }

E10. 环形链表-力扣 141 题

本题以及下题,实际是 Floyd’s Tortoise and Hare Algorithm (Floyd 龟兔赛跑算法)[15]

除了 Floyd 判环算法外,还有其它的判环算法,详见 https://en.wikipedia.org/wiki/Cycle_detection

数据结构与算法1 - 图465

如果链表上存在环,那么在环上以不同速度前进的两个指针必定会在某个时刻相遇。算法分为两个阶段
阶段1

  • 龟一次走一步,兔子一次走两步
  • 当兔子能走到终点时,不存在环
  • 当兔子能追上龟时,可以判断存在环

阶段2

  • 从它们第一次相遇开始,龟回到起点,兔子保持原位不变
  • 龟和兔子一次都走一步
  • 当再次相遇时,地点就是环的入口

为什么呢?

  • 设起点到入口走 a 步(本例是 7),绕环一圈长度为 b(本例是 5),
  • 那么从起点开始,走 a + 绕环 n 圈,都能找到环入口
  • 第一次相遇时
    • 兔走了 a + 绕环 n 圈(本例 2 圈) + k,k 是它们相遇距环入口位置(本例 3,不重要)
    • 龟走了 a + 绕环 n 圈(本例 0 圈) + k,当然它绕的圈数比兔少
    • 兔走的距离是龟的两倍,所以龟走的 = 兔走的 - 龟走的 = 绕环 n 圈
  • 而前面分析过,如果走 a + 绕环 n 圈,都能找到环入口,因此从相遇点开始,再走 a 步,就是环入口

阶段1 参考代码(判断是否有环)

  1. public boolean hasCycle(ListNode head) {
  2. ListNode h = head; // 兔
  3. ListNode t = head; // 龟
  4. while (h != null && h.next != null) {
  5. t = t.next;
  6. h = h.next.next;
  7. if(h == t){
  8. return true;
  9. }
  10. }
  11. return false;
  12. }

E11. 环形链表-力扣 142 题

阶段2 参考代码(找到环入口)

  1. public ListNode detectCycle(ListNode head) {
  2. ListNode t = head; // 龟
  3. ListNode h = head; // 兔
  4. while (h != null && h.next != null) {
  5. t = t.next;
  6. h = h.next.next;
  7. if (h == t) {
  8. t = head;
  9. while (true) {
  10. if (h == t) {
  11. return h;
  12. }
  13. h = h.next;
  14. t = t.next;
  15. }
  16. }
  17. }
  18. return null;
  19. }

Ex1. 删除节点-力扣 237 题

这道题目比较简单,留给大家自己练习
例如

  1. 输入:head = [4,5,1,9], node = 5
  2. 输出:[4,1,9]
  3. 输入:head = [4,5,1,9], node = 1
  4. 输出:[4,5,9]

注意:被删除的节点不是末尾节点

参考答案

  1. public class Ex1Leetcode237 {
  2. /**
  3. *
  4. * @param node 待删除节点, 题目已说明肯定不是最后一个节点
  5. */
  6. public void deleteNode(ListNode node) {
  7. node.val = node.next.val; // 下一个节点值赋值给待"删除"节点
  8. node.next = node.next.next; // 把下一个节点删除
  9. }
  10. public static void main(String[] args) {
  11. ListNode o5 = new ListNode(5, null);
  12. ListNode o4 = new ListNode(4, o5);
  13. ListNode o3 = new ListNode(3, o4);
  14. ListNode o2 = new ListNode(2, o3);
  15. ListNode o1 = new ListNode(1, o2);
  16. System.out.println(o1);
  17. new E0xLeetcode237().deleteNode(o3);
  18. System.out.println(o1);
  19. }
  20. }

输出

  1. [1,2,3,4,5]
  2. [1,2,4,5]

Ex2. 共尾链表-力扣 160 题

原题叫做相交链表,个人觉得用共尾链表更形象些,此题更像是一道脑筋急转弯,留给大家练习
例如,下图的两个链表 [1, 2, 4, 5] 与 [3, 4, 5] 它们中 [4, 5] 是相同的,此时应返回节点 4
数据结构与算法1 - 图466

非共尾的情况,如下图所示,此时返回 null
数据结构与算法1 - 图467

思路,称两个链表为 a=[1, 2, 4, 5],b=[3, 4, 5],图中用 N 代表 null

  1. 遍历 a,遇到 null 时改道遍历 b
  2. 与此同时,遍历 b,遇到 null 时改道遍历 a
  3. 在此过程中,如果遇到相同的节点,即为找寻目标,返回即可,如下图中的第二次出现的 4
  4. 相同节点应该比较其引用值,图中数字只是为了便于区分
    1. 1 2 4 5 N 3 4 5 N
    2. 3 4 5 N 1 2 4 5 N

如果两个链表长度相同,则可以更早找到目标,例如 a=[1, 4, 5],b=[3, 4, 5],第一次出现 4 时,即可返回

  1. 1 4 5 N 3 4 5 N
  2. 3 4 5 N 1 4 5 N

如果是非共尾的情况,如 a=[1, 2, 4],b=[3, 5],可以看到,唯一相等的情况,是遍历到最后那个 N 此时退出循环

  1. 1 2 4 N 3 5 N
  2. 3 5 N 1 2 4 N

代码

  1. public ListNode getIntersectionNode(ListNode a, ListNode b) {
  2. ListNode p1 = a;
  3. ListNode p2 = b;
  4. while (true) {
  5. if (p1 == p2) {
  6. return p1;
  7. }
  8. if (p1 == null) {
  9. p1 = b;
  10. } else {
  11. p1 = p1.next;
  12. }
  13. if (p2 == null) {
  14. p2 = a;
  15. } else {
  16. p2 = p2.next;
  17. }
  18. }
  19. }

3.6 数组

E01. 合并有序数组

将数组内两个区间内的有序元素合并

  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=[]){
    2. merge(left=[5,6],right=[2,4,10,11],a2=[1]){
    3. merge(left=[5,6],right=[4,10,11],a2=[1,2]){
    4. merge(left=[5,6],right=[10,11],a2=[1,2,4]){
    5. merge(left=[6],right=[10,11],a2=[1,2,4,5]){
    6. merge(left=[],right=[10,11],a2=[1,2,4,5,6]){
    7. // 拷贝10,11
    8. }
    9. }
    10. }
    11. }
    12. }
    13. }

代码

  1. public static void merge(int[] a1, int i, int iEnd, int j, int jEnd,
  2. int[] a2, int k) {
  3. if (i > iEnd) {
  4. System.arraycopy(a1, j, a2, k, jEnd - j + 1);
  5. return;
  6. }
  7. if (j > jEnd) {
  8. System.arraycopy(a1, i, a2, k, iEnd - i + 1);
  9. return;
  10. }
  11. if (a1[i] < a1[j]) {
  12. a2[k] = a1[i];
  13. merge(a1, i + 1, iEnd, j, jEnd, a2, k + 1);
  14. } else {
  15. a2[k] = a1[j];
  16. merge(a1, i, iEnd, j + 1, jEnd, a2, k + 1);
  17. }
  18. }

测试

  1. int[] a1 = {1, 5, 6, 2, 4, 10, 11};
  2. int[] a2 = new int[a1.length];
  3. merge(a1, 0, 2, 3, 6, a2, 0);

方法2
代码

  1. public static void merge(int[] a1, int i, int iEnd,
  2. int j, int jEnd,
  3. int[] a2) {
  4. int k = i;
  5. while (i <= iEnd && j <= jEnd) {
  6. if (a1[i] < a1[j]) {
  7. a2[k] = a1[i];
  8. i++;
  9. } else {
  10. a2[k] = a1[j];
  11. j++;
  12. }
  13. k++;
  14. }
  15. if (i > leftEnd) {
  16. System.arraycopy(a1, j, a2, k, jEnd - j + 1);
  17. }
  18. if (j > rightEnd) {
  19. System.arraycopy(a1, i, a2, k, iEnd - i + 1);
  20. }
  21. }

测试

  1. int[] a1 = {1, 5, 6, 2, 4, 10, 11};
  2. int[] a2 = new int[a3.length];
  3. merge(a1, 0, 2, 3, 6, a2);

归并排序代码备份

  1. public static void split(int[] a1, int i, int j, int[] a2) {
  2. System.out.println("i=" + i + " j=" + j + " a1=" + Arrays.toString(Arrays.copyOfRange(a1, i, j + 1)));
  3. if (i == j) {
  4. return;
  5. }
  6. int m = (i + j) >>> 1;
  7. split(a1, i, m, a2);
  8. split(a1, m + 1, j, a2);
  9. //merge(a1, i, m, m+1, j, a2); // 非递归
  10. //merge(a1, i, m, m + 1, j, a2, i); // 递归
  11. System.arraycopy(a2, i, a1, i, (j - i + 1));
  12. System.out.println("i=" + i + " m=" + m + " j=" + j + " a1=" + Arrays.toString(a1) + " a2=" + Arrays.toString(a2));
  13. }
  14. int[] a1 = {1, 5, 6, 2, 4, 10, 11};
  15. int[] a2 = new int[a1.length];
  16. split(a1, 0, a1.length - 1, a2);
  17. System.out.println(Arrays.toString(a1));

3.7 队列

E01. 二叉树层序遍历-力扣 102 题

  1. class Solution {
  2. public List<List<Integer>> levelOrder(TreeNode root) {
  3. List<List<Integer>> result = new ArrayList<>();
  4. if(root == null) {
  5. return result;
  6. }
  7. LinkedListQueue<TreeNode> queue = new LinkedListQueue<>();
  8. queue.offer(root);
  9. int c1 = 1; // 本层节点个数
  10. while (!queue.isEmpty()) {
  11. int c2 = 0; // 下层节点个数
  12. List<Integer> level = new ArrayList<>();
  13. for (int i = 0; i < c1; i++) {
  14. TreeNode node = queue.poll();
  15. level.add(node.val);
  16. if (node.left != null) {
  17. queue.offer(node.left);
  18. c2++;
  19. }
  20. if (node.right != null) {
  21. queue.offer(node.right);
  22. c2++;
  23. }
  24. }
  25. c1 = c2;
  26. result.add(level);
  27. }
  28. return result;
  29. }
  30. // 自定义队列
  31. static class LinkedListQueue<E> {
  32. private static class Node<E> {
  33. E value;
  34. Node<E> next;
  35. public Node(E value, Node<E> next) {
  36. this.value = value;
  37. this.next = next;
  38. }
  39. }
  40. private final Node<E> head = new Node<>(null, null);
  41. private Node<E> tail = head;
  42. int size = 0;
  43. private int capacity = Integer.MAX_VALUE;
  44. {
  45. tail.next = head;
  46. }
  47. public LinkedListQueue() {
  48. }
  49. public LinkedListQueue(int capacity) {
  50. this.capacity = capacity;
  51. }
  52. public boolean offer(E value) {
  53. if (isFull()) {
  54. return false;
  55. }
  56. Node<E> added = new Node<>(value, head);
  57. tail.next = added;
  58. tail = added;
  59. size++;
  60. return true;
  61. }
  62. public E poll() {
  63. if (isEmpty()) {
  64. return null;
  65. }
  66. Node<E> first = head.next;
  67. head.next = first.next;
  68. if (first == tail) {
  69. tail = head;
  70. }
  71. size--;
  72. return first.value;
  73. }
  74. public E peek() {
  75. if (isEmpty()) {
  76. return null;
  77. }
  78. return head.next.value;
  79. }
  80. public boolean isEmpty() {
  81. return head == tail;
  82. }
  83. public boolean isFull() {
  84. return size == capacity;
  85. }
  86. }
  87. }

Ex1. 设计队列-力扣 622 题

由于与课堂例题差别不大,这里只给出参考解答
基于链表的实现

  1. public class Ex1Leetcode622 {
  2. private static class Node {
  3. int value;
  4. Node next;
  5. Node(int value, Node next) {
  6. this.value = value;
  7. this.next = next;
  8. }
  9. }
  10. private final Node head = new Node(-1, null);
  11. private Node tail = head;
  12. private int size = 0;
  13. private int capacity = 0;
  14. {
  15. tail.next = head;
  16. }
  17. public Ex1Leetcode622(int capacity) {
  18. this.capacity = capacity;
  19. }
  20. public boolean enQueue(int value) {
  21. if(isFull()) {
  22. return false;
  23. }
  24. Node added = new Node(value, head);
  25. tail.next = added;
  26. tail = added;
  27. size++;
  28. return true;
  29. }
  30. public boolean deQueue() {
  31. if(isEmpty()) {
  32. return false;
  33. }
  34. Node first = head.next;
  35. head.next = first.next;
  36. if (first == tail) {
  37. tail = head;
  38. }
  39. size--;
  40. return true;
  41. }
  42. public int Front() {
  43. if(isEmpty()) {
  44. return -1;
  45. }
  46. return head.next.value;
  47. }
  48. public int Rear() {
  49. if(isEmpty()) {
  50. return -1;
  51. }
  52. return tail.value;
  53. }
  54. public boolean isEmpty() {
  55. return head == tail;
  56. }
  57. public boolean isFull() {
  58. return size == capacity;
  59. }
  60. }

注意:

  • Leetcode 的实现里 deQueue(出队)返回值是布尔值,并不会返回队头元素
  • 它期望用法是先用 Front 返回对头元素,再 deQueue 出队

3.8 栈

E01. 有效的括号-力扣 20 题

一个字符串中可能出现 [] (){} 三种括号,判断该括号是否有效
有效的例子

  1. ()[]{}
  2. ([{}])
  3. ()

无效的例子

  1. [)
  2. ([)]
  3. ([]

思路

  • 遇到左括号, 把要配对的右括号放入栈顶
  • 遇到右括号, 若此时栈为空, 返回 false,否则把它与栈顶元素对比
    • 若相等, 栈顶元素弹出, 继续对比下一组
    • 若不等, 无效括号直接返回 false
  • 循环结束
    • 若栈为空, 表示所有括号都配上对, 返回 true
    • 若栈不为空, 表示右没配对的括号, 应返回 false

答案(用到了课堂案例中的 ArrayStack 类)

  1. public boolean isValid(String s) {
  2. ArrayStack<Character> stack = new ArrayStack<>(s.length() / 2 + 1);
  3. for (int i = 0; i < s.length(); i++) {
  4. char c = s.charAt(i);
  5. if (c == '(') {
  6. stack.push(')');
  7. } else if (c == '[') {
  8. stack.push(']');
  9. } else if (c == '{') {
  10. stack.push('}');
  11. } else {
  12. if (!stack.isEmpty() && stack.peek() == c) {
  13. stack.pop();
  14. } else {
  15. return false;
  16. }
  17. }
  18. }
  19. return stack.isEmpty();
  20. }

E02. 后缀表达式求值-力扣 120 题

后缀表达式也称为逆波兰表达式,即运算符写在后面

  • 从左向右进行计算
  • 不必考虑运算符优先级,即不用包含括号

示例

  1. 输入:tokens = ["2","1","+","3","*"]
  2. 输出:9
  3. 即:(2 + 1) * 3
  4. 输入:tokens = ["4","13","5","/","+"]
  5. 输出:6
  6. 即:4 + (13 / 5)

题目假设

  • 数字都视为整数
  • 数字和运算符个数给定正确,不会有除零发生

代码

  1. public int evalRPN(String[] tokens) {
  2. LinkedList<Integer> numbers = new LinkedList<>();
  3. for (String t : tokens) {
  4. switch (t) {
  5. case "+" -> {
  6. Integer b = numbers.pop();
  7. Integer a = numbers.pop();
  8. numbers.push(a + b);
  9. }
  10. case "-" -> {
  11. Integer b = numbers.pop();
  12. Integer a = numbers.pop();
  13. numbers.push(a - b);
  14. }
  15. case "*" -> {
  16. Integer b = numbers.pop();
  17. Integer a = numbers.pop();
  18. numbers.push(a * b);
  19. }
  20. case "/" -> {
  21. Integer b = numbers.pop();
  22. Integer a = numbers.pop();
  23. numbers.push(a / b);
  24. }
  25. default -> numbers.push(Integer.parseInt(t));
  26. }
  27. }
  28. return numbers.pop();
  29. }

E03. 中缀表达式转后缀

  1. public class E03InfixToSuffix {
  2. /*
  3. 思路
  4. 1. 遇到数字, 拼串
  5. 2. 遇到 + - * /
  6. - 优先级高于栈顶运算符 入栈
  7. - 否则将栈中高级或平级运算符出栈拼串, 本运算符入栈
  8. 3. 遍历完成, 栈中剩余运算符出栈拼串
  9. - 先出栈,意味着优先运算
  10. 4. 带 ()
  11. - 左括号直接入栈
  12. - 右括号要将栈中直至左括号为止的运算符出栈拼串
  13. | |
  14. | |
  15. | |
  16. _____
  17. a+b
  18. a+b-c
  19. a+b*c
  20. a*b+c
  21. (a+b)*c
  22. */
  23. public static void main(String[] args) {
  24. System.out.println(infixToSuffix("a+b"));
  25. System.out.println(infixToSuffix("a+b-c"));
  26. System.out.println(infixToSuffix("a+b*c"));
  27. System.out.println(infixToSuffix("a*b-c"));
  28. System.out.println(infixToSuffix("(a+b)*c"));
  29. System.out.println(infixToSuffix("a+b*c+(d*e+f)*g"));
  30. }
  31. static String infixToSuffix(String exp) {
  32. LinkedList<Character> stack = new LinkedList<>();
  33. StringBuilder sb = new StringBuilder(exp.length());
  34. for (int i = 0; i < exp.length(); i++) {
  35. char c = exp.charAt(i);
  36. switch (c) {
  37. case '+', '-', '*', '/' -> {
  38. if (stack.isEmpty()) {
  39. stack.push(c);
  40. } else {
  41. if (priority(c) > priority(stack.peek())) {
  42. stack.push(c);
  43. } else {
  44. while (!stack.isEmpty()
  45. && priority(stack.peek()) >= priority(c)) {
  46. sb.append(stack.pop());
  47. }
  48. stack.push(c);
  49. }
  50. }
  51. }
  52. case '(' -> {
  53. stack.push(c);
  54. }
  55. case ')' -> {
  56. while (!stack.isEmpty() && stack.peek() != '(') {
  57. sb.append(stack.pop());
  58. }
  59. stack.pop();
  60. }
  61. default -> {
  62. sb.append(c);
  63. }
  64. }
  65. }
  66. while (!stack.isEmpty()) {
  67. sb.append(stack.pop());
  68. }
  69. return sb.toString();
  70. }
  71. static int priority(char c) {
  72. return switch (c) {
  73. case '(' -> 0;
  74. case '*', '/' -> 2;
  75. case '+', '-' -> 1;
  76. default -> throw new IllegalArgumentException("不合法字符:" + c);
  77. };
  78. }
  79. }

E04. 双栈模拟队列-力扣 232 题

给力扣题目用的自实现栈,可以定义为静态内部类

  1. class ArrayStack<E> {
  2. private E[] array;
  3. private int top; // 栈顶指针
  4. @SuppressWarnings("all")
  5. public ArrayStack(int capacity) {
  6. this.array = (E[]) new Object[capacity];
  7. }
  8. public boolean push(E value) {
  9. if (isFull()) {
  10. return false;
  11. }
  12. array[top++] = value;
  13. return true;
  14. }
  15. public E pop() {
  16. if (isEmpty()) {
  17. return null;
  18. }
  19. return array[--top];
  20. }
  21. public E peek() {
  22. if (isEmpty()) {
  23. return null;
  24. }
  25. return array[top - 1];
  26. }
  27. public boolean isEmpty() {
  28. return top == 0;
  29. }
  30. public boolean isFull() {
  31. return top == array.length;
  32. }
  33. }

参考解答,注意:题目已说明

  • 调用 push、pop 等方法的次数最多 100
  1. public class E04Leetcode232 {
  2. /*
  3. 队列头 队列尾
  4. s1 s2
  5. 顶 底 底 顶
  6. abc
  7. push(a)
  8. push(b)
  9. push(c)
  10. pop()
  11. */
  12. ArrayStack<Integer> s1 = new ArrayStack<>(100);
  13. ArrayStack<Integer> s2 = new ArrayStack<>(100);
  14. public void push(int x) {
  15. s2.push(x);
  16. }
  17. public int pop() {
  18. if (s1.isEmpty()) {
  19. while (!s2.isEmpty()) {
  20. s1.push(s2.pop());
  21. }
  22. }
  23. return s1.pop();
  24. }
  25. public int peek() {
  26. if (s1.isEmpty()) {
  27. while (!s2.isEmpty()) {
  28. s1.push(s2.pop());
  29. }
  30. }
  31. return s1.peek();
  32. }
  33. public boolean empty() {
  34. return s1.isEmpty() && s2.isEmpty();
  35. }
  36. }

E05. 单队列模拟栈-力扣 225 题

给力扣题目用的自实现队列,可以定义为静态内部类

  1. public class ArrayQueue3<E> {
  2. private final E[] array;
  3. int head = 0;
  4. int tail = 0;
  5. @SuppressWarnings("all")
  6. public ArrayQueue3(int c) {
  7. c -= 1;
  8. c |= c >> 1;
  9. c |= c >> 2;
  10. c |= c >> 4;
  11. c |= c >> 8;
  12. c |= c >> 16;
  13. c += 1;
  14. array = (E[]) new Object[c];
  15. }
  16. public boolean offer(E value) {
  17. if (isFull()) {
  18. return false;
  19. }
  20. array[tail & (array.length - 1)] = value;
  21. tail++;
  22. return true;
  23. }
  24. public E poll() {
  25. if (isEmpty()) {
  26. return null;
  27. }
  28. E value = array[head & (array.length - 1)];
  29. head++;
  30. return value;
  31. }
  32. public E peek() {
  33. if (isEmpty()) {
  34. return null;
  35. }
  36. return array[head & (array.length - 1)];
  37. }
  38. public boolean isEmpty() {
  39. return head == tail;
  40. }
  41. public boolean isFull() {
  42. return tail - head == array.length;
  43. }
  44. }

参考解答,注意:题目已说明

  • 调用 push、pop 等方法的次数最多 100
  • 每次调用 pop 和 top 都能保证栈不为空
public class E05Leetcode225 {
    /*
        队列头     队列尾
        cba
        顶           底

        queue.offer(a)
        queue.offer(b)
        queue.offer(c)
     */
    ArrayQueue3<Integer> queue = new ArrayQueue3<>(100);
    int size = 0;
    public void push(int x) {
        queue.offer(x);
        for (int i = 0; i < size; i++) {
            queue.offer(queue.poll());
        }
        size++;
    }

    public int pop() {
        size--;
        return queue.poll();
    }

    public int top() {
        return queue.peek();
    }

    public boolean empty() {
        return queue.isEmpty();
    }
}

3.9 双端队列

E01. 二叉树 Z 字层序遍历-力扣 103 题

public class E01Leetcode103 {
    public List<List<Integer>> zigzagLevelOrder(TreeNode root) {
        List<List<Integer>> result = new ArrayList<>();
        if (root == null) {
            return result;
        }
        LinkedList<TreeNode> queue = new LinkedList<>();
        queue.offer(root);
        boolean leftToRight = true;
        int c1 = 1;
        while (!queue.isEmpty()) {
            int c2 = 0;
            LinkedList<Integer> deque = new LinkedList<>();
            for (int i = 0; i < c1; i++) {
                TreeNode n = queue.poll();
                if (leftToRight) {
                    deque.offerLast(n.val);
                } else {
                    deque.offerFirst(n.val);
                }
                if (n.left != null) {
                    queue.offer(n.left);
                    c2++;
                }
                if (n.right != null) {
                    queue.offer(n.right);
                    c2++;
                }
            }
            c1 = c2;
            leftToRight = !leftToRight;
            result.add(deque);
        }

        return result;
    }

    public static void main(String[] args) {
        TreeNode root = new TreeNode(
                new TreeNode(
                        new TreeNode(4),
                        2,
                        new TreeNode(5)
                ),
                1,
                new TreeNode(
                        new TreeNode(6),
                        3,
                        new TreeNode(7)
                )
        );
        List<List<Integer>> lists = new E01Leetcode103().zigzagLevelOrder(root);
        for (List<Integer> list : lists) {
            System.out.println(list);
        }
    }
}

Ex1. 设计双端队列-力扣 641 题

与课堂例题也是差别不大,请参考

3.10 优先级队列

E01. 合并多个有序链表-力扣 23 题

这道题目之前解答过,现在用刚学的优先级队列来实现一下

题目中要从小到大排列,因此选择用小顶堆来实现,自定义小顶堆如下

public class MinHeap {

    ListNode[] array;
    int size;

    public MinHeap(int capacity) {
        array = new ListNode[capacity];
    }

    public void offer(ListNode offered) {
        int child = size++;
        int parent = (child - 1) / 2;
        while (child > 0 && offered.val < array[parent].val) {
            array[child] = array[parent];
            child = parent;
            parent = (child - 1) / 2;
        }
        array[child] = offered;
    }

    public ListNode poll() {
        if (isEmpty()) {
            return null;
        }
        swap(0, size - 1);
        size--;
        ListNode e = array[size];
        array[size] = null; // help GC

        down(0);

        return e;
    }

    private void down(int parent) {
        int left = 2 * parent + 1;
        int right = left + 1;
        int min = parent;
        if (left < size && array[left].val < array[min].val) {
            min = left;
        }
        if (right < size && array[right].val < array[min].val) {
            min = right;
        }
        if (min != parent) {
            swap(min, parent);
            down(min);
        }
    }

    private void swap(int i, int j) {
        ListNode t = array[i];
        array[i] = array[j];
        array[j] = t;
    }

    public boolean isEmpty() {
        return size == 0;
    }
}

代码

public class E01Leetcode23 {
    public ListNode mergeKLists(ListNode[] lists) {
        // 1. 使用 jdk 的优先级队列实现
//        PriorityQueue<ListNode> queue = new PriorityQueue<>(Comparator.comparingInt(a -> a.val));
        // 2. 使用自定义小顶堆实现
        MinHeap queue = new MinHeap(lists.length);
        for (ListNode head : lists) {
            if (head != null) {
                queue.offer(head);
            }
        }
        ListNode s = new ListNode(-1, null);
        ListNode p = s;
        while (!queue.isEmpty()) {
            ListNode node = queue.poll();
            p.next = node;
            p = node;
            if (node.next != null) {
                queue.offer(node.next);
            }
        }
        return s.next;
    }
}

提问:

  • 能否将每个链表的所有元素全部加入堆,再一个个从堆顶移除?

回答:

  • 可以是可以,但对空间占用就高了,堆的一个优点就是用有限的空间做事情

3.11 堆

E01. 堆排序

算法描述

  1. heapify 建立大顶堆
  2. 将堆顶与堆底交换(最大元素被交换到堆底),缩小并下潜调整堆
  3. 重复第二步直至堆里剩一个元素

可以使用之前课堂例题的大顶堆来实现

int[] array = {1, 2, 3, 4, 5, 6, 7};
MaxHeap maxHeap = new MaxHeap(array);
System.out.println(Arrays.toString(maxHeap.array));

while (maxHeap.size > 1) {
    maxHeap.swap(0, maxHeap.size - 1);
    maxHeap.size--;
    maxHeap.down(0);
}
System.out.println(Arrays.toString(maxHeap.array));

E02. 数组中第K大元素-力扣 215 题

小顶堆(可删去用不到代码)

class MinHeap {
    int[] array;
    int size;

    public MinHeap(int capacity) {
        array = new int[capacity];
    }

    private void heapify() {
        for (int i = (size >> 1) - 1; i >= 0; i--) {
            down(i);
        }
    }

    public int poll() {
        swap(0, size - 1);
        size--;
        down(0);
        return array[size];
    }

    public int poll(int index) {
        swap(index, size - 1);
        size--;
        down(index);
        return array[size];
    }

    public int peek() {
        return array[0];
    }

    public boolean offer(int offered) {
        if (size == array.length) {
            return false;
        }
        up(offered);
        size++;
        return true;
    }

    public void replace(int replaced) {
        array[0] = replaced;
        down(0);
    }

    private void up(int offered) {
        int child = size;
        while (child > 0) {
            int parent = (child - 1) >> 1;
            if (offered < array[parent]) {
                array[child] = array[parent];
            } else {
                break;
            }
            child = parent;
        }
        array[child] = offered;
    }

    private void down(int parent) {
        int left = (parent << 1) + 1;
        int right = left + 1;
        int min = parent;
        if (left < size && array[left] < array[min]) {
            min = left;
        }
        if (right < size && array[right] < array[min]) {
            min = right;
        }
        if (min != parent) {
            swap(min, parent);
            down(min);
        }
    }

    // 交换两个索引处的元素
    private void swap(int i, int j) {
        int t = array[i];
        array[i] = array[j];
        array[j] = t;
    }
}

题解

public int findKthLargest(int[] numbers, int k) {
    MinHeap heap = new MinHeap(k);
    for (int i = 0; i < k; i++) {
        heap.offer(numbers[i]);
    }
    for (int i = k; i < numbers.length; i++) {
        if(numbers[i] > heap.peek()){
            heap.replace(numbers[i]);
        }
    }
    return heap.peek();
}

求数组中的第 K 大元素,使用堆并不是最佳选择,可以采用快速选择算法

E03. 数据流中第K大元素-力扣 703 题

上题的小顶堆加一个方法

class MinHeap {
    // ...
    public boolean isFull() {
        return size == array.length;
    }
}

题解

class KthLargest {

    private MinHeap heap;

    public KthLargest(int k, int[] nums) {
        heap = new MinHeap(k);
        for(int i = 0; i < nums.length; i++) {
            add(nums[i]);
        }
    }

    public int add(int val) {
        if(!heap.isFull()){
            heap.offer(val);
        } else if(val > heap.peek()){
            heap.replace(val);
        }
        return heap.peek();
    }

}

求数据流中的第 K 大元素,使用堆最合适不过

E04. 数据流的中位数-力扣 295 题

可以扩容的 heap, max 用于指定是大顶堆还是小顶堆

public class Heap {
    int[] array;
    int size;
    boolean max;

    public int size() {
        return size;
    }

    public Heap(int capacity, boolean max) {
        this.array = new int[capacity];
        this.max = max;
    }

    /**
     * 获取堆顶元素
     *
     * @return 堆顶元素
     */
    public int peek() {
        return array[0];
    }

    /**
     * 删除堆顶元素
     *
     * @return 堆顶元素
     */
    public int poll() {
        int top = array[0];
        swap(0, size - 1);
        size--;
        down(0);
        return top;
    }

    /**
     * 删除指定索引处元素
     *
     * @param index 索引
     * @return 被删除元素
     */
    public int poll(int index) {
        int deleted = array[index];
        swap(index, size - 1);
        size--;
        down(index);
        return deleted;
    }

    /**
     * 替换堆顶元素
     *
     * @param replaced 新元素
     */
    public void replace(int replaced) {
        array[0] = replaced;
        down(0);
    }

    /**
     * 堆的尾部添加元素
     *
     * @param offered 新元素
     */
    public void offer(int offered) {
        if (size == array.length) {
            grow();
        }
        up(offered);
        size++;
    }

    private void grow() {
        int capacity = size + (size >> 1);
        int[] newArray = new int[capacity];
        System.arraycopy(array, 0,
                newArray, 0, size);
        array = newArray;
    }

    // 将 offered 元素上浮: 直至 offered 小于父元素或到堆顶
    private void up(int offered) {
        int child = size;
        while (child > 0) {
            int parent = (child - 1) / 2;
            boolean cmp = max ? offered > array[parent] : offered < array[parent];
            if (cmp) {
                array[child] = array[parent];
            } else {
                break;
            }
            child = parent;
        }
        array[child] = offered;
    }

    public Heap(int[] array, boolean max) {
        this.array = array;
        this.size = array.length;
        this.max = max;
        heapify();
    }

    // 建堆
    private void heapify() {
        // 如何找到最后这个非叶子节点  size / 2 - 1
        for (int i = size / 2 - 1; i >= 0; i--) {
            down(i);
        }
    }

    // 将 parent 索引处的元素下潜: 与两个孩子较大者交换, 直至没孩子或孩子没它大
    private void down(int parent) {
        int left = parent * 2 + 1;
        int right = left + 1;
        int min = parent;
        if (left < size && (max ? array[left] > array[min] : array[left] < array[min])) {
            min = left;
        }
        if (right < size && (max ? array[right] > array[min] : array[right] < array[min])) {
            min = right;
        }
        if (min != parent) { // 找到了更大的孩子
            swap(min, parent);
            down(min);
        }
    }

    // 交换两个索引处的元素
    private void swap(int i, int j) {
        int t = array[i];
        array[i] = array[j];
        array[j] = t;
    }
}

题解

private Heap left = new Heap(10, false);
private Heap right = new Heap(10, true);

/**
 为了保证两边数据量的平衡
 <ul>
  <li>两边数据一样时,加入左边</li>
  <li>两边数据不一样时,加入右边</li>
 </ul>
 但是, 随便一个数能直接加入吗?
 <ul>
  <li>加入左边前, 应该挑右边最小的加入</li>
  <li>加入右边前, 应该挑左边最大的加入</li>
 </ul>
 */
public void addNum(int num) {
    if (left.size() == right.size()) {
        right.offer(num);
        left.offer(right.poll());
    } else {
        left.offer(num);
        right.offer(left.poll());
    }
}

/**
 * <ul>
 *     <li>两边数据一致, 左右各取堆顶元素求平均</li>
 *     <li>左边多一个, 取左边元素</li>
 * </ul>
 */
public double findMedian() {
    if (left.size() == right.size()) {
        return (left.peek() + right.peek()) / 2.0;
    } else {
        return left.peek();
    }
}

本题还可以使用平衡二叉搜索树求解,不过代码比两个堆复杂

3.12 二叉树

E04. 对称二叉树-力扣 101 题

public boolean isSymmetric(TreeNode root) {
    return check(root.left, root.right);
}

public boolean check(TreeNode left, TreeNode right) {
    // 若同时为 null
    if (left == null && right == null) {
        return true;
    }
    // 若有一个为 null (有上一轮筛选,另一个肯定不为 null)
    if (left == null || right == null) {
        return false;
    }
    if (left.val != right.val) {
        return false;
    }
    return check(left.left, right.right) && check(left.right, right.left);
}

类似题目:Leetcode 100 题 - 相同的树

E05. 二叉树最大深度-力扣 104 题

后序遍历求解

/*
    思路:
    1. 得到左子树深度, 得到右子树深度, 二者最大者加一, 就是本节点深度
    2. 因为需要先得到左右子树深度, 很显然是后序遍历典型应用
    3. 关于深度的定义:从根出发, 离根最远的节点总边数,
        注意: 力扣里的深度定义要多一

        深度2         深度3         深度1
        1            1            1
       / \          / \
      2   3        2   3
                        \
                         4
 */
public int maxDepth(TreeNode node) {
    if (node == null) {
        return 0; // 非力扣题目改为返回 -1
    }
    int d1 = maxDepth(node.left);
    int d2 = maxDepth(node.right);
    return Integer.max(d1, d2) + 1;
}

后序遍历求解-非递归

/*
    思路:
    1. 使用非递归后序遍历, 栈的最大高度即为最大深度
 */
public int maxDepth(TreeNode root) {
    TreeNode curr = root;
    LinkedList<TreeNode> stack = new LinkedList<>();
    int max = 0;
    TreeNode pop = null;
    while (curr != null || !stack.isEmpty()) {
        if (curr != null) {
            stack.push(curr);
            int size = stack.size();
            if (size > max) {
                max = size;
            }
            curr = curr.left;
        } else {
            TreeNode peek = stack.peek();
            if(peek.right == null || peek.right == pop) {
                pop = stack.pop();
            } else {
                curr = peek.right;
            }
        }
    }
    return max;
}

层序遍历求解

/*
    思路:
    1. 使用层序遍历, 层数即最大深度
 */
public int maxDepth(TreeNode root) {
    if(root == null) {
        return 0;
    }
    Queue<TreeNode> queue = new LinkedList<>();
    queue.offer(root);
    int level = 0;
    while (!queue.isEmpty()) {
        level++;
        int size = queue.size();
        for (int i = 0; i < size; i++) {
            TreeNode node = queue.poll();
            if (node.left != null) {
                queue.offer(node.left);
            }
            if (node.right != null) {
                queue.offer(node.right);
            }
        }
    }
    return level;
}

E06. 二叉树最小深度-力扣 111 题

后序遍历求解

public int minDepth(TreeNode node) {
    if (node == null) {
        return 0;
    }
    int d1 = minDepth(node.left);
    int d2 = minDepth(node.right);
    if (d1 == 0 || d2 == 0) {
        return d1 + d2 + 1;
    }
    return Integer.min(d1, d2) + 1;
}

相较于求最大深度,应当考虑:

  • 当右子树为 null,应当返回左子树深度加一
  • 当左子树为 null,应当返回右子树深度加一

上面两种情况满足时,不应该再把为 null 子树的深度 0 参与最小值比较,例如这样

    1
   /
  2
  • 正确深度为 2,若把为 null 的右子树的深度 0 考虑进来,会得到错误结果 1
    1
     \
      3
       \
        4
  • 正确深度为 3,若把为 null 的左子树的深度 0 考虑进来,会得到错误结果 1

层序遍历求解

遇到的第一个叶子节点所在层就是最小深度

例如,下面的树遇到的第一个叶子节点 3 所在的层就是最小深度,其他 4,7 等叶子节点深度更深,也更晚遇到

     1
    / \     
   2   3
  / \
 4   5 
    /
   7

代码

public int minDepth(TreeNode root) {
    if(root == null) {
        return 0;
    }
    Queue<TreeNode> queue = new LinkedList<>();
    queue.offer(root);
    int level = 0;
    while (!queue.isEmpty()) {
        level++;
        int size = queue.size();
        for (int i = 0; i < size; i++) {
            TreeNode node = queue.poll();
            if (node.left == null && node.right == null) {
                return level;
            }
            if (node.left != null) {
                queue.offer(node.left);
            }
            if (node.right != null) {
                queue.offer(node.right);
            }
        }
    }
    return level;
}

效率会高于之前后序遍历解法,因为找到第一个叶子节点后,就无需后续的层序遍历了

E07. 翻转二叉树-力扣 226 题

public TreeNode invertTree(TreeNode root) {
    fn(root);
    return root;
}

private void fn(TreeNode node){
    if (node == null) {
        return;
    }
    TreeNode t = node.left;
    node.left = node.right;
    node.right = t;
    fn(node.left);
    fn(node.right);
}

先交换、再递归或是先递归、再交换都可以

E08. 后缀表达式转二叉树

static class TreeNode {
    public String val;
    public TreeNode left;
    public TreeNode right;

    public TreeNode(String val) {
        this.val = val;
    }

    public TreeNode(TreeNode left, String val, TreeNode right) {
        this.left = left;
        this.val = val;
        this.right = right;
    }

    @Override
    public String toString() {
        return this.val;
    }
}

/*
    中缀表达式           (2-1)*3
    后缀(逆波兰)表达式   21-3*

    1.遇到数字入栈
    2.遇到运算符, 出栈两次, 与当前节点建立父子关系, 当前节点入栈

    栈
    |   |
    |   |
    |   |
    _____

    表达式树
        *
       / \
      -   3
     / \
    2   1

    21-3*
 */
public TreeNode constructExpressionTree(String[] tokens) {
    LinkedList<TreeNode> stack = new LinkedList<>();
    for (String t : tokens) {
        switch (t) {
            case "+", "-", "*", "/" -> { // 运算符
                TreeNode right = stack.pop();
                TreeNode left = stack.pop();
                TreeNode parent = new TreeNode(t);
                parent.left = left;
                parent.right = right;
                stack.push(parent);
            }
            default -> { // 数字
                stack.push(new TreeNode(t));
            }
        }
    }
    return stack.peek();
}

E09. 根据前序与中序遍历结果构造二叉树-力扣 105 题

  • 先通过前序遍历结果定位根节点
  • 再结合中序遍历结果切分左右子树
public class E09Leetcode105 {

    /*
        preOrder = {1,2,4,3,6,7}
        inOrder = {4,2,1,6,3,7}

        根 1
            pre         in
        左  2,4         4,2
        右  3,6,7       6,3,7


        根 2
        左 4

        根 3
        左 6
        右 7
     */

    public TreeNode buildTree(int[] preOrder, int[] inOrder) {
        if (preOrder.length == 0) {
            return null;
        }
        // 创建根节点
        int rootValue = preOrder[0];
        TreeNode root = new TreeNode(rootValue);
        // 区分左右子树
        for (int i = 0; i < inOrder.length; i++) {
            if (inOrder[i] == rootValue) {
                // 0 ~ i-1 左子树
                // i+1 ~ inOrder.length -1 右子树
                int[] inLeft = Arrays.copyOfRange(inOrder, 0, i); // [4,2]
                int[] inRight = Arrays.copyOfRange(inOrder, i + 1, inOrder.length); // [6,3,7]

                int[] preLeft = Arrays.copyOfRange(preOrder, 1, i + 1); // [2,4]
                int[] preRight = Arrays.copyOfRange(preOrder, i + 1, inOrder.length); // [3,6,7]

                root.left = buildTree(preLeft, inLeft); // 2
                root.right = buildTree(preRight, inRight); // 3
                break;
            }
        }
        return root;
    }

}
  • 代码可以进一步优化,涉及新数据结构,以后实现

E10. 根据中序与后序遍历结果构造二叉树-力扣 106 题

  • 先通过后序遍历结果定位根节点
  • 再结合中序遍历结果切分左右子树
public TreeNode buildTree(int[] inOrder, int[] postOrder) {
    if (inOrder.length == 0) {
        return null;
    }
    // 根
    int rootValue = postOrder[postOrder.length - 1];
    TreeNode root = new TreeNode(rootValue);
    // 切分左右子树
    for (int i = 0; i < inOrder.length; i++) {
        if (inOrder[i] == rootValue) {
            int[] inLeft = Arrays.copyOfRange(inOrder, 0, i);
            int[] inRight = Arrays.copyOfRange(inOrder, i + 1, inOrder.length);

            int[] postLeft = Arrays.copyOfRange(postOrder, 0, i);
            int[] postRight = Arrays.copyOfRange(postOrder, i, postOrder.length - 1);

            root.left = buildTree(inLeft, postLeft);
            root.right = buildTree(inRight, postRight);
            break;
        }
    }
    return root;
}
  • 代码可以进一步优化,涉及新数据结构,以后实现

附录

参考文章

落选题目

反转字符数组

public static void main(String[] args) {
    char[] array = "abcde".toCharArray();
    recursion(array, 0, array.length - 1);
    System.out.println(Arrays.toString(array));
}

public static void recursion(char[] array, int i, int j) {
    if (i >= j) {
        return;
    }
    swap(array, i, j);
    recursion(array, ++i, --j);
}

public static void swap(char[] array, int i, int j) {
    char c = array[i];
    array[i] = array[j];
    array[j] = c;
}
  • 第一次交换的是 array[0] 和 array[4]
  • 第二次交换的是 array[1] 和 array[3]
  • 第三次 i = j = 2,开始返回
  • 如果 array.length 是偶数,则会在 i > j 时返回

力扣高评价题目列表

引用自 面试最常考的 100 道算法题分类整理! - 知乎 (zhihu.com)

带 ✔️ 是本课程讲解过的


  1. “Definition of ALGORITHM”. Merriam-Webster Online Dictionary. Archived from the original on February 14, 2020. Retrieved November 14, 2019. ↩︎
  2. Introduction to Algorithm 中文译作《算法导论》 ↩︎ ↩︎
  3. 主要参考文档 https://en.wikipedia.org/wiki/Binary_search_algorithm ↩︎
  4. 图片及概念均摘自 Introduction to Algorithm 4th,3.1节,3.2 节 ↩︎
  5. jdk 版本有关,64 位 jdk,按 8 字节对齐 ↩︎
  6. 图片引用自 wikipedia linkedlist 条目,https://en.wikipedia.org/wiki/Linked_list ↩︎
  7. Fibonacci 介绍:https://en.wikipedia.org/wiki/Fibonacci_number ↩︎ ↩︎
  8. 几种计算Fibonacci数列算法的时间复杂度比较 - 知乎 (zhihu.com) ↩︎
  9. 几种斐波那契数列算法比较 Fast Fibonacci algorithms (nayuki.io) ↩︎
  10. 我知道的有 C++,Scala ↩︎
  11. 与主定理类似的还有 Akra–Bazzi method,https://en.wikipedia.org/wiki/Akra–Bazzi_method ↩︎
  12. Josephus problem 主要参考 https://en.wikipedia.org/wiki/Josephus_problem ↩︎
  13. 汉诺塔图片资料均来自 https://en.wikipedia.org/wiki/Tower_of_Hanoi ↩︎
  14. 也称为 Pascal’s triangle https://en.wikipedia.org/wiki/Pascal’s_triangle ↩︎
  15. 龟兔赛跑动画来自于 Floyd’s Hare and Tortoise Algorithm Demo - One Step! Code (onestepcode.com) ↩︎