数据结构与算法学习笔记

1. 数据结构与算法

1.1 知识脑图

Alt text

1.2 什么是 数据结构 与 算法

  • 数据结构 就是一组数据的存储结构
  • 算法 就是操作一组数据的方法
  • 数据结构是为算法服务的,算法要作用在特定的数据结构之上

1.3 为什么需要数据结构和算法

  • 在计算机科学和互联网迅猛发展下,需要计算的数据量越来越庞大。但是计算机的计算能力是有限的,这么大量的数据计算,需要越来越多的计算机,需要越来越长的计算时间,注重效率的我们需要尽可能的提高计算效率
  • 选用合适的数据结构和算法,特别是在处理体量非常庞大的数据的时候,可以极大提高计算效率

1.4 怎么样衡量数据结构和算法

  • 我们需要一个不用具体的测试数据来测试,就可以粗略地估计算法的执行效率的方法
  • 衡量的标准(metric) — 时间复杂度空间复杂度

1.4.1 大 O 复杂度表示法

 T(n) = O(f(n))
// T(n) 表示代码执行的时间;
// n 表示数据规模的大小
// f(n) 表示每行代码执行的次数总和,因为这是一个公式,所以用 f(n) 来表示
// O 表示代码的执行时间 T(n) 与 f(n) 表达式成正比

1.4.2 时间复杂度 分析

  • 大 O 时间复杂度 实际上并不具体表示代码真正的执行时间,而是表示代码执行时间随数据规模增长的变化趋势,所以,也叫作渐进时间复杂度(asymptotic time complexity),简称时间复杂度
1.4.2.1 只关注循环执行次数最多的一段代码
  • 大 O 这种复杂度表示方法只是表示一种变化趋势。我们通常会忽略掉公式中的常量、低阶、系数,只需要记录一个最大阶的量级就可以了
 int cal(int n) { //1
   int sum = 0; //2
   int i = 1; //3 
   for (; i <= n; ++i) { //4
     sum = sum + i; //5
   } //6
   return sum; //7
 }
 // 其中第 2、3 行代码都是常量级的执行时间,与 n 的大小无关,所以对于复杂度并没有影响。循环执行次数最多的是第 4、5 行代码,所以这块代码要重点分析。
 // 这两行代码被执行了 n 次,所以总的时间复杂度就是 O(n)
1.4.2.2 加法法则:总复杂度等于量级最大的那段代码的复杂度
如果 T1(n) = O(f(n)),T2(n) = O(g(n));
那么 T(n) = T1(n) + T2(n) = max(O(f(n)), O(g(n))) = O(max(f(n), g(n)))
1.4.2.3 乘法法则:嵌套代码的复杂度等于嵌套内外代码复杂度的乘积
如果 T1(n) = O(f(n)),T2(n) = O(g(n));
那么 T(n) = T1(n) * T2(n) = O(f(n)) * O(g(n)) = O(f(n) *  g(n))
1.4.2.4 常见的几种时间复杂度分析

Alt text

  • 可以粗略地分为两类,多项式量级非多项式量级
  • 其中,非多项式量级只有两个:O(2n) 和 O(n!)
  • 当数据规模 n 越来越大时,非多项式量级算法的执行时间会急剧增加,求解问题的执行时间会无限增长。所以,非多项式时间复杂度的算法其实是非常低效的算法

Alt text

1.4.2.4.1 O(1)
  • O(1) 只是常量级时间复杂度的一种表示方法,并不是指只执行了一行代码
  • 一般情况下,只要算法中不存在循环语句、递归语句,即使有成千上万行的代码,其时间复杂度也是Ο(1)
1.4.2.4.2 O(logn)、O(nlogn)
  • 对数之间是可以互相转换的,log3n 就等于 log32 * log2n,所以 O(log3n) = O(C * log2n),其中 C=log32 是一个常量
  • 基于我们前面的一个理论:在采用大 O 标记复杂度的时候,可以忽略系数,即 O(Cf(n)) = O(f(n))。所以,O(log2n) 就等于 O(log3n)
  • 因此,在对数阶时间复杂度的表示方法里,我们忽略对数的“底”,统一表示为 O(logn)
  • 如果一段代码的时间复杂度是 O(logn),我们循环执行 n 遍,时间复杂度就是 O(nlogn) 了
  • O(nlogn) 也是一种非常常见的算法时间复杂度。比如,归并排序、快速排序的时间复杂度都是 O(nlogn)
1.4.2.4.3 O(m+n)、O(m*n)
  • 代码的复杂度由两个数据的规模来决定
  • 我们无法事先评估 m 和 n 谁的量级大,所以我们在表示复杂度的时候,就不能简单地利用加法法则,省略掉其中一个
1.4.2.5 最好时间复杂度
  • 在最理想的情况下,执行这段代码的时间复杂度
1.4.2.6 最坏时间复杂度
  • 在最糟糕的情况下,执行这段代码的时间复杂度
  • 一般情况下,我们只需考虑最坏时间复杂度就行
1.4.2.7 平均时间复杂度
  • 最好与最坏是在极端情况下发生的,平均情况复杂度引入了概率,所以也叫加权平均时间复杂度或者期望时间复杂度
  • 只有同一块代码在不同的情况下,时间复杂度有量级的差距,我们才会使用这最好,最坏,平均这三种复杂度表示法来区分
1.4.2.8 均摊时间复杂度
  • 应用的场景特殊、有限
  • 出现的频率是非常有规律的,而且有一定的前后时序关系
  • 在能够应用均摊时间复杂度分析的场合,一般均摊时间复杂度就等于最好情况时间复杂度

1.4.3 空间复杂度 分析

  • 大O空间复杂度全称就是渐进空间复杂度(asymptotic space complexity),表示算法的存储空间与数据规模之间的增长关系
  • 是指除了原本的数据存储空间外,算法运行还需要额外的存储空间
  • 常见的空间复杂度就是 O(1)、O(n)、O(n2)

1.5 算法概述

  • 算法就是一种解析问题的思路或者说方案
  • 算法没有最好的,只有最适合的

1.5.1 特性

  • 输入
  • 输出
  • 有穷性
  • 确定性
  • 可行性

1.5.2 基本要求

  • 正确性
  • 可读性
  • 健壮性
  • 时间复杂度 (运行时间越快越好)
  • 空间复杂度 (占用资源越少越好)

2. 各种数据结构简析

2.1 数组

  • 是一种线性表数据结构
  • 连续的内存空间存储相同类型的一组数据
  • 支持随机访问(时间复杂度为O(1))
  • 插入、删除操作比较低效 (时间复杂度为O(n))
  • 对于业务开发,直接使用容器就足够了,省时省力。毕竟损耗一丢丢性能,完全不会影响到系统整体的性能。
  • 但如果是做一些非常底层的开发,比如开发网络框架,性能的优化需要做到极致,这个时候数组就会优于容器,成为首选

2.1.1 线性表

  • 顾名思义,就是数据排成像一条线一样的结构。每个线性表上的数据最多只有前和后两个方向

Alt text

2.1.2 非线性表

  • 数据之间并不是简单的前后关系

Alt text

2.1.3 连续的内存空间和相同类型的数据

  • 这个特点 使之 具有 “随机访问” 的杀手锏
  • 寻址公式:
a[k]_address = base_address + k * data_type_size

// data_type_size 表示数组中每个元素的大小
2.1.3.1 对 CPU 缓存更友好
  • CPU 缓存是从内存中读取一个数据块(一段连续的内存地址),并保存到CPU缓存中
  • 下次访问内存数据的时候就会先从CPU缓存开始查找,如果找到就不需要再从内存中取。这样就实现了比内存访问速度更快的机制,也就是CPU缓存存在的意义:为了弥补内存访问速度过慢与CPU执行速度快之间的差异而引入

2.1.4 为什么数组的下标要从0开始,而不是1开始?

  • “下标” 最确切的定义应该是 “偏移(offset)”
  • 寻址公式对比:
// 下标从0开始
a[k]_address = base_address + k * data_type_size

// 下标从1开始
a[k]_address = base_address + (k - 1) * data_type_size

// data_type_size 表示数组中每个元素的大小
  • 从 1 开始编号,每次随机访问数组元素都多了一次减法运算,对于 CPU 来说,就是多了一次减法指令,所以为了减少一次减法操作,数组选择了从 0 开始编号,而不是从 1 开始
  • 最主要的原因还是历史原因,C 语言设计者用 0 开始计数数组下标,之后的 Java、JavaScript 等高级语言都效仿了 C 语言
  • 实际上,很多语言中数组也并不是从 0 开始计数的,比如 Matlab;Python 更是支持负数下标

2.1.5 扩展

2.1.5.1 二维数组的寻址公式
二维数组内存寻址:

对于 m * n 的数组,a [ i ][ j ] (i < m,j < n)的地址为:

address = base_address + (i * n + j) * data_type_size
2.1.5.2 C 语言中 数组越界导致无限循环问题
int main(int argc, char* argv[]){
    int i = 0;
    int arr[3] = {0};
    for(; i<=3; i++){
        arr[i] = 0;
        printf("hello world\n");
    }
    return 0;
}
// 在 C 语言中,只要不是访问受限的内存,所有的内存空间都是可以自由访问的
// 所以当i = 3 时,数组已经越界,a[3]也会被定位到某块不属于数组的内存地址上,
// 如果编译器的内存分配是递减的,那么这个地址正好是存储变量 i 的内存地址,那么 a[3]=0 就相当于 i=0,所以就会导致代码无限循环

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-XYXE5sOZ-1631413187041)(https://note.youdao.com/yws/api/personal/file/3081FADEBE684E72A555E28F08A4E799?method=download&shareKey=f9123f73f012c10757490a40cbe799c7)]

2.2 链表

  • 不需要连续的内存空间,通过每个节点的指针,连成一个链表。(通过指针将一组零散的内存块(结点)串联在一起
  • 不支持随机访问,查询的 时间复杂度为O(n)
  • 插入、删除操作比较高效 (时间复杂度为O(1))
  • 单链表,循环链表,双向链表

2.2.1 单链表

Alt text

  • 头节点:第一个结点,用来记录链表的基地址
  • 尾节点:最后一个结点,指向一个空地址 NULL
2.2.1.1 高效的插入与删除

Alt text

  • 虽然单纯地插入和删除的时间复杂度只有O(1),但查询到指定结点的时间复杂度却要 O(n)

2.2.2 循环链表

Alt text

  • 特殊的单链表
  • 尾结点指针是指向链表的头结点

2.2.3 双向链表

Alt text

  • 前驱指针 prev 指向前面的结点
  • 双向链表可以支持 O(1) 时间复杂度的情况下找到前驱结点,正是这样的特点,也使双向链表在某些情况下的插入、删除等操作都要比单链表简单、高效
2.2.3.1 删除的两种情况
2.2.3.1.1 删除结点中“值等于某个给定值”的结点
  • 这种情况,单链表和双向链表都要遍历整个链表找到给定的值的结点
  • 时间复杂度都为O(n)
2.2.3.1.2 删除给定指针指向的结点
  • 这种情况已经找到了要删除的结点,但是删除某个结点 q 需要知道其前驱结点
  • 单链表并不支持直接获取前驱结点,所以,为了找到前驱结点,我们还是要从头结点开始遍历链表 – O(n)
  • 双向链表中的结点已经保存了前驱结点的指针,不需要像单链表那样遍历 – O(1)

2.2.4 链表的编程注意事项和技巧

2.2.4.1 理解指针或引用的含义
  • 不管是“指针”还是“引用”,实际上,它们的意思都是一样的,都是存储所指对象的内存地址
  • 将某个变量赋值给指针,实际上就是将这个变量的地址赋值给指针,或者反过来说,指针中存储了这个变量的内存地址,指向了这个变量,通过指针就能找到这个变量
2.2.4.2 警惕指针丢失和内存泄漏
  • 写链表代码的时候,指针指来指去,一会儿就不知道指到哪里了。所以,我们在写的时候,一定注意不要弄丢了指针
  • 插入结点时,一定要注意操作的顺序
  • 删除链表结点时,也一定要记得手动释放内存空间

Alt text

p.next = x;
x.next = p.next
// 会丢失指针,因为p.next 已经指向了 x

//应该改为下面这样
x.next = p.next;
p.next = x;
2.2.4.3 利用哨兵简化实现难度
  • 针对链表的插入、删除操作,需要对插入第一个结点和删除最后一个结点的情况进行特殊处理
  • 哨兵,解决的是国家之间的边界问题。同理,这里说的哨兵也是解决“边界问题”的,不直接参与业务逻辑
  • 哨兵结点是不存储数据
  • 有哨兵结点的链表叫带头链表

Alt text

2.2.4.4 重点留意边界条件处理
  • 如果链表为空时,代码是否能正常工作?
  • 如果链表只包含一个结点时,代码是否能正常工作?
  • 如果链表只包含两个结点时,代码是否能正常工作?
  • 代码逻辑在处理头结点和尾结点的时候,是否能正常工作?

2.2.5 如何基于链表实现 LRU 缓存淘汰算法

  • LRU (最近最少使用 Least Recently Used)
  • 思路:维护一个有序单链表,越靠近链表尾部的结点是越早之前访问的。当有一个新的数据被访问时,从链表头开始顺序遍历链表
  • 1. 如果此数据之前已经被缓存在链表中了,我们遍历得到这个数据对应的结点,并将其从原来的位置删除,然后再插入到链表的头部
  • 2. 如果此数据没有在缓存链表中,又可以分为两种情况
  • 如果此时缓存未满,则将此结点直接插入到链表的头部
  • 如果此时缓存已满,则链表尾结点删除,将新的数据结点插入链表的头部
  • 时间复杂度为O(n),引入散列表后,记录每个数据的位置,可将时间复杂度降为O(1)
2.2.5.1 扩展:基于数组怎么实现 LRU 缓存淘汰算法?
  • 思路1:首位置保存最新访问数据,末尾位置优先清理
  • 当有新数据访问时:
  • 如果在缓存中,那么将找到数组中对应的数据删除掉,并将新数据插入到数组第一个位置。这个操作,数组中的数据都要往后移动一位,复杂度为O(n);
  • 如果不在缓存中:
  • 缓存未满,直接将新数据插入到数组第一个位置即可,数组中的数据都要往后移动一位,复杂度O(n)
  • 缓存已满,将数组尾部数据删除掉,将新数据插入到数组第一个位置,数组中的数据都要往后移动一位,复杂度O(n)
  • 思路2:末尾位置保存最新访问数据,首位置优先清理
  • 当有新数据访问时:
  • 如果在缓存中,那么将找到数组中对应的数据删除掉,并将新数据插入到数组末尾,这个操作,数组中的数据都要往前移动一位,复杂度为O(n);
  • 如果不在缓存中:
  • 缓存未满,直接将新数据插入到数组末尾位置即可,数组中的数据都要往后移动一位,复杂度O(n)
  • 缓存已满,将数组第一个数据删除掉,数组中的数据都要往前移动一位,将新数据插入到数组第末尾位置,复杂度O(n)

2.2.6 如果字符串是通过单链表来存储的,那该如何来判断该字符串是一个回文串?

  • 回文串又称为“水仙花字符串”,比如 “上海自来水来自海上”
  • 回文的核心就是对称,所以关键是找到对称点
  • 思路一:以空间换时间
  • 遍历链表,将链表中的字符倒序存储一份在另一个链表中
  • 同时遍历两个链表,对比两个链表中字符,如果相等就是回文,不相等就不是。
  • 时间复杂度O(n),空间复杂度O(n)
  • 思路二:快慢指针法
  • 快指针 每步走两步,慢指针 每步走一步,并且每走一步将链表反向
  • 当快指针走到末尾时,慢指针刚好到中心对称点
  • 然后再增加一个指针,从中心点往回走(因为前半部分已经反向),慢指针接着往后走,并比较每一步的值
  • 如果相等就说明是,不相等就不是
  • 时间复杂度O(n),空间复杂度O(1)(因为没有额外增加空间,只是多了3个辅助的指针而已)

2.3 栈

  • 栈是一种 “操作受限”的线性表只允许在一端插入和删除数据
  • 当某个数据集合只涉及在一端插入和删除数据,并且满足 后进先出、先进后出 的特性,我们就应该首选“栈”这种数据结构
  • 用数组实现的栈,我们叫作顺序栈
  • 用链表实现的栈,我们叫作链式栈

2.3.1 栈的应用

2.3.1.1 栈在函数调用中的应用 —— 函数调用栈
  • 操作系统给每个线程分配了一块独立的内存空间,这块内存被组织成“栈”这种结构, 用来存储函数调用时的临时变量
  • 每进入一个函数,就会将临时变量作为一个栈帧入栈
  • 当被调用函数执行完成,返回之后,将这个函数对应的栈帧出栈
int main() {
   int a = 1; 
   int ret = 0;
   int res = 0;
   ret = add(3, 5);
   res = a + ret;
   printf("%d", res);
   reuturn 0;
}

int add(int x, int y) {
   int sum = 0;
   sum = x + y;
   return sum;
}

Alt text()

2.3.1.2 栈在表达式求值中的应用
  • 编译器就是通过两个栈来实现的。
  • 其中一个保存操作数的栈,另一个是保存运算符的栈。
  • 从左向右遍历表达式,当遇到数字,我们就直接压入操作数栈;当遇到运算符,就与运算符栈的栈顶元素进行比较
  • 如果比运算符栈顶元素的优先级高,就将当前运算符压入栈
  • 如果比运算符栈顶元素的优先级低或者相同,从运算符栈中取栈顶运算符,从操作数栈的栈顶取 2 个操作数,然后进行计算,再把计算完的结果压入操作数栈,继续比较

Alt text()

2.3.1.3 栈在括号匹配中的作用
  • 用栈来保存未匹配的左括号,从左到右依次扫描字符串。
  • 当扫描到左括号时,则将其压入栈中;当扫描到右括号时,从栈顶取出一个左括号。如果能够匹配,比如“(”跟“)”匹配,“[”跟“]”匹配,“{”跟“}”匹配,则继续扫描剩下的字符串。
  • 如果扫描的过程中,遇到不能配对的右括号,或者栈中没有数据,则说明为非法格式。
  • 当所有的括号都扫描完成之后,如果栈为空,则说明字符串为合法格式;否则,说明有未匹配的左括号,为非法格式。

2.3.2 为什么函数调用要用“栈”来保存临时变量呢?用其他数据结构不行吗?

  • 因为栈的 “先进后出”的特性 刚好符合函数临时变量的作用域(只要能保证每进入一个新的函数,都是一个新的作用域就可以。而要实现这个,用栈就非常方便。在进入被调用函数的时候,分配一段栈空间给这个函数的变量,在函数结束的时候,将栈顶复位,正好回到调用函数的作用域内
  • 原则上 只要符合 函数临时变量的作用域的数据结构,都可以用来保存临时变量。栈可以用数组和链表来实现。

2.3.3 JVM 里面的“栈”跟我们这里说的“栈”是不是一回事呢?

  • 严格来说 不是一回事,JVM 的栈是操作系统的一段虚拟内存,而 通常说的“栈” 是一种抽象的数据结构
  • 但总体来说,也可以说是一样的,他们都符合栈的特性。

2.4 队列

  • 先进先出,后进后出
  • 顺序队列:数组实现,可以实现一个有界队列(bounded queue)
  • 链式队列:链表实现,可以实现一个支持无限排队的无界队列(unbounded queue)

2.4.1 循环队列

  • 顾名思义它长的像一个环
    Alt text
  • 要想写出没有 bug 的循环队列的实现代码,最关键的是,确定好队空和队满的判定条件
在用数组实现的
非循环队列中:
    队满的判断条件是 tail == n,
    队空的判断条件是 head == tail
循环队列:
    队满的判断条件是 (tail+1)%n=head,
    队空的判断条件是 head == tail
  • 当队列满时,图中的 tail 指向的位置实际上是没有存储数据的。所以,循环队列会浪费一个数组的存储空间
    Alt text

2.4.2 阻塞队列

  • 其实就是在队列基础上增加了阻塞操作。
  • 简单来说,就是在队列为空的时候,从队头取数据会被阻塞。因为此时还没有数据可取,直到队列中有了数据才能返回;
  • 如果队列已经满了,那么插入数据的操作就会被阻塞,直到队列中有空闲位置后再插入数据,然后再返回
  • 用阻塞队列 可以 实现 “生产者-消费者”模式

2.4.3 并发队列

  • 就是线程安全的队列
  • 最简单直接的实现方式是直接在 enqueue()、dequeue() 方法上加锁,但是锁粒度大并发度会比较低,同一时刻仅允许一个存或者取操作。
  • 基于数组的循环队列,利用 CAS 原子操作,可以实现非常高效的并发队列
在入队前,获取tail位置,
入队时比较tail是否发生变化,如果否,则允许入队,反之,本次入队失败。
出队则是获取head位置,进行cas

3. 递归 (Recursion)-- 是一种非常高效、简洁的编程技巧

3.1 概念

  • 函数调用自身,称为递归

3.2 递归需要满足的三个条件

  • 3.2.1 一个问题的解可以分解为几个子问题的解
  • 3.2.2 这个问题与分解后的子问题,除了数据规模不同,求解思路完全一样
  • 3.2.3 存在递归终止条件

3.3 如何理解和编写递归代码

  • 找到如何将大问题分解为小问题的规律
  • 基于规律写出递归公式
  • 推敲出终止条件
  • 最后将递归公式和终止条件翻译成代码
  • 不用想一层层的调用关系,不要试图用人脑去分解递归的每个步骤

3.4 递归的弊端及其避免

3.4.1 警惕堆栈的溢出

  • 1.不要递归太深,超过一定深度时,直接返回报错
  • 2.使用尾递归
  • 3.用迭代循环换掉递归

3.4.2 警惕重复计算

  • 通过一个数据结构(比如散列表)来保存已求解过的f(k)。当递归调用到f(k)时,先看下是否已经求解过,如果是,则直接从散列表中取值返回,不需要重复计算

3.5 尾递归

3.5.1 尾调用

  • 某个函数的最后一步是调用另一个函数
function f(x) {
    return g(x);
}
3.5.1.1 尾调用优化
  • 函数调用会在内存中形成一个“调用帧”,保存调用位置和内部变量等信息
  • 尾调用由于是函数的最后一步操作,所以不需要保留外层函数的调用记录,只要直接用内层函数的调用记录就可以
  • 这样将大大节省内存,这就是“尾调用优化”的意义。

3.5.2 尾递归概念

  • 如果尾调用自身,并且return语句不能包含表达式,就称为尾递归
3.5.2.1 尾递归优化
  • 确定是尾递归后,编译器或者解释器可以把尾递归做优化,是递归无论调用多少次,都只占用一个栈帧,不会出现栈溢出的现象
  • java,js等是支持尾递归优化的
  • 普通递归,例如:n的阶乘
//每次都要保存调用信息,空间复杂度 O(n)
function fastorial(n) {
    if (n === 1) {
        return n;
    }
    return n * fastorial(n-1);
}
  • 尾递归
//只需要保留最后一个调用记录,空间复杂度 O(1)
function fastorial(n,total) {
    if (n === 1) {
        return total;
    }
    return fastorial(n-1,n*total);
}

4. 排序

Alt text

  • 原地排序:特指空间复杂度为O(1)的排序算法
  • 稳定排序:相等元素的原有先后顺序不变
  • 有序度:数组中具有有序性(升序)的元素的对数
有序元素对:a[i] <= a[j], 如果i < j。
  • 满有序度:完全有序的数组的有序度,等于 n*(n-1)/2
  • 逆序度:数组中具有无序性(降序)的元素的对数,与有序度刚好相反。逆序度 = 满有序度 - 有序度

4.1 冒泡排序(BubbleSort)

  • 冒泡:最大/小的先冒出来
  • 相邻的两个数相比较,每次排序都会将一个数放到合适的位置
  • 时间复杂度:O(n2)
  • 空间复杂度:O(1)
  • 是一种稳定的,原地排序算法
/** 用数组实现*/
public static void bubbleSort (int[] arr) {
        if (Objects.isNull(arr) || arr.length <= 1) {
            return;
        }
        int size = arr.length;
        for (int i = 0;i < size;i++) {
//            for (int j = i+1;j < size;j++) {
//                if (arr[i] > arr[j]) {
//                    int temp = arr[i];
//                    arr[i] = arr[j];
//                    arr[j] = temp;
//                }
//            }
            //提前退出冒泡循环的标记位
            boolean flag = false;
            for (int j = 0;j < size - i - 1;j++) {
                if (arr[j] > arr[j+1]) {
                    int temp = arr[j];
                    arr[j] = arr[j+1];
                    arr[j+1] = temp;
                    //说明发生了交换
                    flag = true;
                }
            }
            if (!flag) {
                break;
            }
        }
    }
    
/** 用链表实现*/
class Node {
    int value;
    Node next;
    Node(int value) {
        this.value = value;
    }
    
    @Override
    public String toString() {
        if (this.next == null) {
            return String.valueOf(this.value);
        }
        return this.value + "->" + this.next.toString();
    }
}

//方法一,交换节点的值 (一般情况下 不允许)
public Node bubbleSort1 (Node head) {
    if (head == null) {
        return head;
    }
    Node temp = head;
    int len = 0;
    //获取数组的长度
    while (temp != null) {
        temp = temp.next;
        len++;
    }
    for (int i = 0;i < len;i++) {
        Node current = head;
        boolean flag = false;
        for (j = 0;i < len - i - 1;j++) {
            if (current.value > current.next.value) {
                //交换值
                int tempValue = current.value;
                current.value = current.next.value;
                current.next.value = temp;
                flag = true;
            }
            current = current.next;
        }
        if (!flag) {
            break;
        }
    }
    return head;
    
}

//方法二,交换节点指针
public Node bubbleSort2 (Node head) {
    if (head == null) {
        return head;
    }
    Node temp = head;
    int len = 0;
    //获取链表的长度
    while (temp != null) {
        temp = temp.next;
        len++;
    }
    // 缓存新链表头结点
    Node newHead = head;
    
    for (int i = 0;i < len;i++) {
        //每次循环都是冒泡一次的链表
        Node current = newHead;
        Node pre = null;
        boolean flag = false;
        for (j = 0;i < len - i - 1;j++) {
            if (current.value > current.next.value) {
               //1、交换两个节点的引用,此时current的指针交换后会前移,只需要更新pre指向即可
               
               //缓存下下个节点
               Node tempNode = current.next.next;
               
               //下个节点 指向当前节点  反向
               current.next.next = current;
               
               //前一个节点指向 current.next
               if (pre != null) {
                   pre.next = current.next;
               } else { //说明当前节点为头节点,那么更新头节点
                   newHead = current.next;
               }
               //前节点相应后移一位
               pre = current.next;
               //当前节点指向下下个节点
               current.next = tempNdoe;
               flag = true;
            } else {
                pre = current;
                current = current.next;
            }
            
        }
        if (!flag) {
            break;
        }
    }
    return head;
    
}

4.2 插入排序

  • 将要排序的数组,分成 已排序区间 和 未排序区间;初始的已排序区间只有第一个元素
  • 取未排序区间的元素,插入到已排序区间中,并保证已排序区间的数据一直有序
  • 重复这个过程,直到未排序区间中元素个数为空
  • 时间复杂度:O(n2)
  • 空间复杂度:O(1)
  • 是一种稳定的,原地排序算法
 public static void insertionSort (int[] arr) {
        if (Objects.isNull(arr) || arr.length <= 1) {
            return;
        }
        for (int i = 1;i < arr.length;i++) {
            int temp = arr[i];
            int j = i - 1;
            for (;j >= 0;j--) {
                if (arr[j] > temp) {
                    //将大于temp的值,后移一位;交换数据
                    arr[j+1] = arr[j];
                } else {
                    break;
                }
            }
            //插入数据
            arr[j+1] = temp;
        }
    }

4.3 选择排序

  • 将要排序的数组,分成 已排序区间 和 未排序区间;初始的已排序区间没有元素
  • 从未排序区间中找到最小的元素,将其放到已排序区间的末尾
  • 重复这个过程,直到未排序区间中元素个数为空
  • 时间复杂度:O(n2)
  • 空间复杂度:O(1)
  • 是一种不稳定的,原地排序算法
 public static void selectionSort (int[] arr) {
        if (Objects.isNull(arr) || arr.length <= 1) {
            return;
        }
        //找到未排序空间的最小值的索引
        for (int i = 0;i < arr.length;i++) {
            int least = i;
            for (int j = i+1;j < arr.length;j++) {
                if (arr[j] < arr[i]) {
                    least = j;
                }
            }
            //将最小值放到排序空间的末尾(与排序空间的最后一个元素交换位置)
            int temp = arr[i];
            arr[i] = arr[least];
            arr[least] = temp;
        }
    }

4.4 归并排序

  • 分治思想,先分解后合并
  • 分解:将要排序的数组,从中间分成两部分,然后对两部分分别进行排序;以此类推,直到不能分为止。(利用递归实现)
  • 合并:将排好序的两部分,合并成一个排好序的新数组)
  • 时间复杂度:O(nlogn)
  • 空间复杂度:O(n) 因为合并时,新增了一个长度为n的数组
  • 是一种稳定的,非原地排序算法
public static void mergeSort (int a[]) {
        if (a != null) {
            int len = a.length;
            mergeDivide(a,0,len-1);
        }
    }

    private static void mergeDivide (int[] a,int p,int r) {
        //递归终止条件
        if (p >= r) {
            return;
        }
        // 取p到r之间的中间位置q
        int q = p + (r-p)/2;
        // 分治递归
        mergeDivide(a,p,q);
        mergeDivide(a,q+1,r);
        // 合并
       // merge(a,p,q,r);
        merge2(a,p,q,r);
    }

    // 不带哨兵模式
    private static void merge (int[] a,int p, int q,int r) {
        //申请一个与a同样大小的临时数组
        int[] temp = new int[r - p + 1];
        int i = p;
        int j = q+1;
        int k = 0;
        while (i <= q && j <= r) {
            if (a[i] <= a[j]) {
                temp[k++] = a[i++];
            } else {
                temp[k++] = a[j++];
            }
        }
        //判断哪个子数组还有剩余的数据
        int start = i;
        int end = q;
        if (j <= r) {
            start = j;
            end = r;
        }
        // 将剩余的数据拷贝到临时数组tmp
        while (start <= end) {
            temp[k++] = a[start++];
        }
        // 将tmp中的数组拷贝回a[p...r]
        for (int m = 0;m <= r-p;m++) {
            a[p+m] = temp[m];
        }
    }

    // 哨兵模式
    private static void merge2 (int[] a,int p, int q,int r) {
        //申请左右两个临时数组
        int[] left = new int[q - p + 2];
        int[] right = new int[r - q + 1];
        int leftMax = left.length - 1;
        int rightMax = right.length - 1;
        for (int i = 0;i < leftMax;i++) {
            left[i] = a[p+i];
        }
        for (int i = 0;i < rightMax;i++) {
            right[i] = a[q+1+i];
        }
        left[leftMax] = Integer.MAX_VALUE;
        right[rightMax] = Integer.MAX_VALUE;
        //比较大小,把有序数据放回原数组 利用哨兵简化,如果左右部分的最后一个元素都是最大且相等,左边结束时右边也已经结束
        //不带哨兵的递归是比较过后,把有序数据放入temp数组
        int i = 0;
        int j = 0;
        int k = p;
        while (k <= r) {
            if (left[i] < right[j]) {
                a[k++] = left[i++];
            } else {
                a[k++] = right[j++];
            }
        }
    }

4.5 快速排序

  • 分治思想,先分区然后处理每个分区
  • 随机选择要排序数组的一个数据作为分区点pv(一般取数组的最后一个元素为分区点),然后将小于pv的放到左边,大于pv的放到右边。重复这个步骤,直到无法分区
  • 核心是分区函数其实现类似与选择排序
  • 时间复杂度:O(nlogn)
  • 空间复杂度:O(1)
  • 是一种不稳定的,原地排序算法
public static void quickSort(int[] a,int left,int right) {
        if (left >= right) {
            return;
        }
        //进行分区,并返回分区后中心点的索引值
        int middle = partition(a,left,right);

        //递归 分区
        quickSort(a,left,middle-1);
        quickSort(a,middle + 1,right);
    }

    private static int partition (int[] a,int left,int right) {
        //选择最后一个为分区点
        int pivot = a[right];
        //i-1为已处理区间 -- 比pivot小
        int i = left;
        //j为未处理区间,从其中找出比pivot小的数值,与已处理区间末尾的数据(比pivot大)交换
        for (int j = left;j < right;j++) {
            if (a[j] < pivot) {
                int temp = a[i];
                a[i] = a[j];
                a[j] = temp;
                i++;
            }
        }
        //将pivot 放到 中间 (已处理区间末尾)
        int temp = a[i];
        a[i] = pivot;
        a[right] = temp;
        return i;
    }

4.6 归并排序与快排都用了分治算法的思想,他们的区别在哪?

Alt text

  • 归并排序:由下到上的,先处理子问题,然后再合并
  • 快速排序:由上而下的,先分区,然后再处理子问题

4.6.1 有10个300M的日志文件,每个文件里面的每行数据都按时间戳升序排好序,现在要将其合并为一个文件,并且要求文件的数据还是按时间戳升序,此时机器内存只有1G,该怎么解决?

  • 开10个文件通道,初始每个通道加载100M的日志到内存,充分利用内存,实现高速读写(当然为了考虑到实际因素,可留200M内存,给服务器其他程序用,每个通道加载80M的数据)
  • 创建一个数组,容量为10,读取内存中每个文件的首位记录,放入数组并升序排序
  • 取数组首位记录,写入新文件
  • 从数组首位记录所属文件读取首位记录,使用二分查找插入有序数组
  • 重复步骤三
  • 如果内存中的某个文件内存记录读取完毕,则遍历所有文件内存,从磁盘中加载日志记录到内存,保持每个文件100M内存记录,直至整个文件读取完毕

4.7 线性排序

  • 线性排序算法的时间复杂度为O(n)
  • 线性排序算法的空间复杂度为O(n),以空间换时间
  • 线性排序算法基本都不涉及元素之间的比较操作,是非基于比较的排序算法
  • 对排序数据的要求很苛刻,重点掌握此线性排序算法的适用场景

4.7.1 桶排序

4.7.1.1 算法原理:
  • 将要排序的数据分到几个有序的桶里,每个桶里的数据再单独进行快速排序。
  • 桶内排完序之后,再把每个桶里的数据按照顺序依次取出,组成的序列就是有序的了。
4.7.1.2 使用条件
  • 要排序的数据需要很容易就能划分成m个桶,并且桶与桶之间有着天然的大小顺序。
  • 数据在各个桶之间分布是均匀的。
4.7.1.3 适用场景
  • 桶排序比较适合用在外部排序中。
  • 外部排序就是数据存储在外部磁盘且数据量大,但内存有限无法将整个数据全部加载到内存中
4.7.1.4 应用案例
  • 需求描述:
  • 有10GB的订单数据,需按订单金额(假设金额都是正整数)进行排序
    但内存有限,仅几百MB
  • 解决思路:
  • 扫描一遍文件,看订单金额所处数据范围,比如1元-10万元,那么就分100个桶。
  • 第一个桶存储金额1-1000元之内的订单,第二个桶存1001-2000元之内的订单,依次类推。
  • 每个桶对应一个文件,并按照金额范围的大小顺序编号命名(00,01,02,…,99)。
  • 将100个小文件依次放入内存并用快排排序。
  • 所有文件排好序后,只需按照文件编号从小到大依次读取每个小文件并写到大文件中即可。
  • 注意点:若单个文件无法全部载入内存,则针对该文件继续按照前面的思路进行处理即可

4.7.2 计数排序(Counting sort)

4.7.2.1 算法原理
  • 计数其实就是桶排序的一种特殊情况。
  • 当要排序的n个数据所处范围并不大时,比如最大值为k,则分成k个桶
  • 每个桶内的数据值都是相同的,就省掉了桶内排序的时间
4.7.2.2 计数排序的关键步骤
  • 步骤一:扫描待排序数据arr[N],使用计数数组counting[MAX-MIN],对每一个arr[N]中出现的元素进行计数;
  • 步骤二:扫描计数数组counting[],还原arr[N],排序结束;
4.7.2.3 案例分析:
  • 假设待排序的数组,arr={5, 3, 7, 1, 8, 2, 9, 4, 7, 2, 6, 6, 2, 6, 6}
    很容易发现,待排序的元素在[0, 10]之间,可以用counting[0,10]来存储计数

  • 第一步:统计计数
    Alt text

  • 第二步:还原数组
    Alt text

4.7.2.4 使用条件
  • 只能用在数据范围不大的场景中,若数据范围k比要排序的数据n大很多,就不适合用计数排序;
  • 计数排序只能给非负整数排序,其他类型需要在不改变相对大小情况下,转换为非负整数;
  • 比如如果考试成绩精确到小数后一位,就需要将所有分数乘以10,转换为整数。

4.7.3 基数排序(Radix sort)

  • 先以个位数的大小来对数据进行排序,接着以十位数的大小来多数进行排序,接着以百位数的大小……
  • 排到最后,就是一组有序的元素了
4.7.3.1 算法原理(以排序10万个手机号为例来说明)
  • 比较两个手机号码a,b的大小,如果在前面几位中a已经比b大了,那后面几位就不用看了。
  • 借助稳定排序算法的思想,可以先按照最后一位来排序手机号码,然后再按照倒数第二位来重新排序,以此类推,最后按照第一个位重新排序。
  • 经过11次排序后,手机号码就变为有序的了。
  • 每次排序有序数据范围较小,可以使用桶排序或计数排序来完成。
4.7.3.2 使用条件
  • 要求数据可以分割独立的“位”来比较;
  • 位之间由递进关系,如果a数据的高位比b数据大,那么剩下的地位就不用比较了;
  • 每一位的数据范围不能太大,要可以用线性排序,否则基数排序的时间复杂度无法做到O(n)。

4.7.5 思考

4.7.5.1 如何根据年龄给100万用户数据排序?
  • 利用桶排序
  • 假设年龄的范围最小 1 岁,最大不超过 120 岁。
  • 我们可以遍历这 100 万用户,根据年龄将其划分到这 120 个桶里,然后依次顺序遍历这 120 个桶中的元素
4.7.5.2 对D,a,F,B,c,A,z这几个字符串进行排序,要求将其中所有小写字母都排在大写字母前面,但是小写字母内部和大写字母内部不要求有序。比如经过排序后为a,c,z,D,F,B,A,这个如何实现呢?如果字符串中处理大小写,还有数字,将数字放在最前面,又该如何解决呢?
  • 利用桶排序思想,弄小写,大写,数字三个桶,遍历一遍,都放进去,然后再从桶中取出来就行了

5. 二分查找

  • 二分查找针对的是一个有序的数据集合,每次通过跟区间中间的元素对比,将待查找的区间缩小为之前的一半,直到找到要查找的元素,或者区间缩小为0

5.1 二分查找的时间复杂度 – O(logN)

  • 假设数据大小是n,每次查找后数据都会缩小为原来的一半,最坏的情况下,直到查找区间被缩小为空,才停止。
  • 所以,每次查找的数据大小是:n,n/2,n/4,…,n/(2k),…,这是一个等比数列。当n/(2k)=1时,k的值就是总共缩小的次数,也是查找的总次数。而每次缩小操作只涉及两个数据的大小比较,所以,经过k次区间缩小操作,时间复杂度就是O(k)。通过n/(2^k)=1,可求得k=log2n,所以时间复杂度是O(logn)

5.1.1 认识O(logN)

  • 这是一种极其高效的时间复杂度,有时甚至比O(1)的算法还要高效(因为大O表示法,是忽略了常数的,假如这个常数比较大时,比如 10000,这时O(logN)更高效)
  • 因为logn是一个非常“恐怖“的数量级,即便n非常大,对应的logn也很小。比如n等于2的32次方,也就是42亿,而logn才32

5.2 二分查找的实现

5.2.1 简单二分查找的实现

  • 数据中没有重复元素
5.2.1.1 循环
public int binarySearch (int[] a,int val) {
    int start = 0;
    int end = a.length - 1;
    while (start <= end) {
        int mid = start + ((end-start) >> 1); // start + (end - start) / 2
        
        if (a[mid] > val) {
            end = mid - 1;
        } else if (a[mid] < val) {
            start = mid + 1;
        } else {
            return mid;
        }
    }
    return -1;
}
5.2.1.1.1 注意事项
  • 循环退出条件是:start<=end,而不是start<end
  • mid的取值,使用mid=start + (end - start) / 2,而不用mid=(start + end)/2,因为如果start和end比较大的话,求和可能会发生int类型的值超出最大范围。为了把性能优化到极致,可以将除以2转换成位运算,即start + ((end - start) >> 1),因为相比除法运算来说,计算机处理位运算要快得多
  • start和end的更新:start = mid - 1,end = mid + 1,若直接写成start = mid,end=mid,就可能会发生死循环
5.2.1.2 递归
public int binarySearch (int[] a,int val) {
    return subBinarySearch(a,val,0,a.length - 1);
}

private int subBinarySearch (int[] a,int val,int start,int end) {
    if (start > end) {
        return -1;
    }
    int mid = start + ((end - start) >> 1);
    if (a[mid] > val) {
        end = mid -1;
    } else if (a[mid] < val) {
        start = mid + 1;
    } else {
        return mid;
    }
    return subBinarySearch(a,val,start,end);
}

5.2.2 四种常见的二分查找变形问题实现

5.2.2.1 查找第一个等于给定值的元素
public int binarySearch (int[] a,int val) {
    int start = 0;
    int end = a.length - 1;
    while (start <= end) {
        int mid = start + ((end-start) >> 1); // start + (end - start) / 2
        
        if (a[mid] > val) {
            end = mid - 1;
        } else if (a[mid] < val) {
            start = mid + 1;
        } else {
            if ((mid == 0) || (a[mid-1]) != val) {
                return mid;
            } else {
                 end = mid - 1;
            }
        }
    }
    return -1;
}
5.2.2.2 查找最后一个等于给定值的元素
public int binarySearch (int[] a,int val) {
    int start = 0;
    int end = a.length - 1;
    while (start <= end) {
        int mid = start + ((end-start) >> 1); // start + (end - start) / 2
        
        if (a[mid] > val) {
            end = mid - 1;
        } else if (a[mid] < val) {
            start = mid + 1;
        } else {
            if ((mid == a.length - 1) || (a[mid+1]) != val) {
                return mid;
            } else {
                start = mid + 1;
            }
        }
    }
    return -1;
}
5.2.2.3 查找第一个大于等于给定值的元素
public int binarySearch (int[] a,int val) {
    int start = 0;
    int end = a.length - 1;
    while (start <= end) {
        int mid = start + ((end-start) >> 1); // start + (end - start) / 2
        
        if (a[mid] >= val) {
            if ((mid == 0) || (a[mid-1]) < val) {
                return mid;
            } else {
                end = mid - 1;
            }
        } else {
            start = mid + 1;
        }
    }
    return -1;
}
5.2.2.4 查找最后一个小于等于给定值的元素
public int binarySearch (int[] a,int val) {
    int start = 0;
    int end = a.length - 1;
    while (start <= end) {
        int mid = start + ((end-start) >> 1); // start + (end - start) / 2
        
        if (a[mid] <= val) {
            if ((mid == a.length - 1) || (a[mid+1]) > val) {
                return mid;
            } else {
                start = mid + 1;
            }
        } else {
            end = mid - 1;
        }
    }
    return -1;
}

5.3 使用条件(应用场景的局限性)

  • 二分查找依赖的是顺序表结构,即数组
  • 二分查找针对的是有序数据,因此只能用在插入、删除操作不频繁,一次排序多次查找的场景中 (也就是说最适用于静态数据)
  • 数据量太小不适合二分查找,与直接遍历相比效率提升不明显。但有一个例外,就是数据之间的比较操作非常费时,比如数组中存储的都是长度超过300的字符串,那这是还是尽量减少比较操作使用二分查找吧
  • 数据量太大也不是适合用二分查找,因为数组需要连续的空间,若数据量太大,往往找不到存储如此大规模数据的连续内存空间
  • 凡是用二分查找能解决的,绝大部分我们更倾向于用散列表或者二叉查找树。即便是二分查找在内存使用上更节省,但是毕竟内存如此紧缺的情况并不多。
  • 二分查找更适合用在“近似”查找问题,在这类问题上,二分查找的优势更加明显

5.4 思考

5.4.1 如何在1000万个整数中快速查找某个整数?

  • 1000万个整数占用存储空间为40MB,占用空间不大,所以可以全部加载到内存中进行处理
  • 用一个1000万个元素的数组存储,然后使用快排进行升序排序,时间复杂度为O(nlogn)
  • 在有序数组中使用二分查找算法进行查找,时间复杂度为O(logn)
  • 如果数据量达到百亿,千亿级,就用 “布隆过滤器”

5.4.2 如果二分查找的数据,使用链表存储,时间的复杂度是多少?

  • O(n)
  • 假设链表长度为n,二分查找每次都要找到中间点(计算中忽略奇偶数差异):
  • 第一次查找中间点,需要移动指针n/2次;
  • 第二次,需要移动指针n/4次;
  • 第三次需要移动指针n/8次 …,以此类推,直到一次为止
  • 总共指针移动次数(查找次数) = n/2 + n/4 + n/8 + …+ 1,这显然是个等比数列,根据等比数列求和公式:Sum = n - 1.
    最后算法时间复杂度是:O(n-1),忽略常数,记为O(n),时间复杂度和顺序查找时间复杂度相同

5.4.3 如何编程实现“求一个数的平方根”?要求精确到小数点后6位?

//precision 精确度,6位的话,就是0.000001
public static double sqrt (double num,double precision) {
    if (num < 0) {
        return Double.NaN;
    }
    double low = 0;
    double up = num;
    
    if (num > 0 && num < 1) {
        low = num;
        up = 1;
    }
    
    double mid = low + (up - low) / 2;
    
    while (up - low > precision) {
        if (mid > num / mid) { //mid * mid > num 避免溢出,可以写为 mid > num / mid
            up = mid;
        } else if (mid < num / mid) {
            low = mid;
        } else {
            return mid;
        }
        mid = low + (up - low) / 2;
    }
    
    return up;
}

5.4.4 如果有序数组是一个循环有序数组(暂定无重复数值),比如 4,5,6,1,2,3。针对这种情况,如何实现一个求“值等于给定值”的二分查找算法呢?

  • 思路:
    1. 找到分界下标,分成两个有序数组
    1. 判断目标值在哪个有序数据范围内,做二分查找
  • 具体方案:
  • 循环数组存在一个性质:以数组中间点为分区,会将数组分成一个有序数组和一个循环有序数组
  • 如果首元素小于 mid元素,说明前半部分是有序的,后半部分是循环有序数组;
  • 如果首元素大于 mid元素,说明后半部分是有序的,前半部分是循环有序的数组;
  • 如果目标元素在有序数组范围中,使用二分查找;
  • 如果目标元素在循环有序数组中,设定数组边界后,使用以上方法继续查找
class Solution {
    public int search(int[] nums, int target) {
        if (null == nums || nums.length == 0) {
            return -1;
        }
        if (nums.length == 1) {
            if (nums[0] == target) {
                return 0;
            }
            return -1;
        }
        int low = 0;
        int up = nums.length - 1;
        int index = getIndex(nums,low,up);
        if (index != -1) {
            int val = binarySearch(nums,target,low,index);
            if (val != -1) {
                return val;
            }
            return  binarySearch(nums,target,index + 1,up);
        }
        return binarySearch(nums,target,low,up);
    }

    //查找到循环数组的中间点
    private int getIndex (int[] a,int low,int up) {
        if(a.length < 1) {
            return -1;
        }
        while (low <= up) {
            int mid = low + ((up - low) >> 1);
            if (a[mid] > a[mid+1]) {
                return mid;
            } else if (a[mid] < a[low]) {
                up = mid;
            } else if (a[mid] > a[up]) {
                low = mid;
            } else {
                return -1;
            }
        }
        return -1;
    }

    //二分查找
    private int binarySearch (int[] a,int val,int low,int up) {
        while (low <= up) {
            int mid = low + ((up - low) >> 1);
            if (a[mid] > val) {
                up = mid - 1;
            } else if (a[mid] < val) {
                low = mid + 1;
            } else {
                return mid;
            }
        }
        return -1;
    }
}

5.5 跳表 (实现了基于链表的“二分查找”)

  • 是一种各方面性能都比较优秀的动态数据结构
  • 链表加多级索引的结构
  • 空间换时间

Alt text

5.5.1 跳表的查询速度 – O(logn)

  • 每两个结点会抽出一个结点作为上一级索引的结点,那第一级索引的结点个数大约就是 n/2,
  • 第二级索引的结点个数大约就是 n/4,
  • 第三级索引的结点个数大约就是 n/8,
  • 依次类推,也就是说,第 k 级索引的结点个数是第 k-1 级索引的结点个数的 1/2,那第 k级索引结点的个数就是 n/(2k)

5.5.2 跳表是否浪费内存(空间复杂度) – O(n)

  • 实际上,在软件开发中,我们不必太在意索引占用的额外空间。
  • 在讲数据结构和算法时,我们习惯性地把要处理的数据看成整数,
  • 但是在实际的软件开发中,原始链表中存储的有可能是很大的对象
  • 索引结点只需要存储关键值和几个指针,并不需要存储对象,所以当对象比索引结点大很多时,那索引占用的额外空间就可以忽略了

5.5.3 高效的动态插入和删除

  • 时间复杂度 都为 O(logn)
  • 单纯的插入操作是 O(1),耗时主要在查询插入的位置(单链表为0(n)),跳表查询效率为O(logn),所以整体的插入为 O(logn)
  • 删除操作 同理。需要注意的是:如果删除的结点在索引中也有出现,我们除了要删除原始链表中的结点,还要删除索引中的

Alt text

5.5.4 跳表索引动态更新

  • 当不停地往跳表中插入数据时,如果我们不更新索引,就有可能出现某 2 个索引结点之间数据非常多的情况。极端情况下,跳表还会退化成单链表
  • 作为一种动态数据结构,我们需要某种手段来维护索引与原始链表大小之间的平衡,也就是说,如果链表中结点多了,索引结点就相应地增加一些,避免复杂度退化,以及查找、插入、删除操作性能下降
  • 通过随机函数来维护平衡性
  • 当往跳表中插入数据的时候,我们可以选择同时将这个数据插入到部分索引层中
  • 随机函数生成了值 K,那我们就将这个结点添加到第一级到第 K 级这 K 级索引中

Alt text

6 散列表

  • 基本组成:Hash算法 + 数组
    • 基本上就是 HashMap (Hash算法 + 数组 + 链表 + 红黑树)

6.1 哈希算法的基本要求

  • 散列/哈希函数计算得到的散列值是一个非负整数
  • 如果 key1 = key2,那 hash(key1) == hash(key2)
  • 如果 key1 ≠ key2,那 hash(key1) ≠ hash(key2),这一点无法完全满足,所以会有哈希冲突
  • 几乎无法找到一个完美的无冲突的散列函数

6.2 解决哈希冲突

6.2.1 开放寻址法

  • 线性探测Linear Probing) (如果某个数据经过散列函数散列之后,存储位置已经被占用了,我们就从当前位置开始,依次往后查找,看是否有空闲位置,直到找到为止)
  • 二次探测(Quadratic probing) 二次探测探测的步长就变成了原来的“二次方”
  • 双重散列(Double hashing) 使用一组散列函数 hash1(key),hash2(key),hash3(key)……我们先用第一个散列函数,如果计算得到的存储位置已经被占用,再用第二个散列函数,依次类推,直到找到空闲的存储位置
6.2.1.1 装载因子
  • 散列表的装载因子=填入表中的元素个数/散列表的长度
  • 装载因子越大,说明空闲位置越少,冲突越多,散列表的性能会下降

6.2.1 链表法

  • 所有散列值相同的元素我们都放到相同槽位对应的链表中

Alt text

6.3 工业级哈希表的特点 – 比如 Java 中的 HashMap

  • 支持快速的查询、插入、删除操作
  • 内存占用合理,不能浪费过多的内存空间
  • 性能稳定,极端情况下,散列表的性能也不会退化到无法接受的情况

6.3.1 如何实现

  • 设计一个合适的散列函数,不能太复杂,生成的值要尽可能随机并且均匀分布
  • 定义装载因子阈值,并且设计动态扩容策略
  • 选择合适的散列冲突解决方法

6.4 哈希算法

  • 将任意长度的二进制值串映射为固定长度的二进制值串(哈希值),这个映射的规则就是哈希算法

6.4.1 优秀哈希算法需要满足的要求

  • 从哈希值不能反向推导出原始数据(所以哈希算法也叫单向哈希算法)
  • 对输入数据非常敏感,哪怕原始数据只修改了一个 Bit,最后得到的哈希值也大不相同;
  • 散列冲突的概率要很小,对于不同的原始数据,哈希值相同的概率非常小;
  • 哈希算法的执行效率要尽量高效,针对较长的文本,也能快速地计算出哈希值。

6.4.2 哈希算法的常见应用场景

6.4.2.1 安全加密
  • MD5(MD5 Message-Digest Algorithm,MD5 消息摘要算法)
  • SHA(Secure Hash Algorithm,安全散列算法)
  • DES(Data Encryption Standard,数据加密标准)
  • AES(Advanced Encryption Standard,高级加密标准)
  • 即便哈希算法存在冲突,但是在有限的时间和资源下,哈希算法还是被很难破解的
  • 没有绝对安全的加密
6.4.2.1.1 鸽巢原理 / 抽屉原理
  • 如果有 10 个鸽巢,有 11 只鸽子,那肯定有 1 个鸽巢中的鸽子数量多于 1 个,换句话说就是,肯定有 2 只鸽子在 1 个鸽巢内
6.4.2.1.2 为什么哈希算法无法做到零冲突?
  • 哈希算法产生的哈希值的长度是固定且有限的,所以能表示的数据是有限的
  • 而我们要哈希的数据是无穷的
  • 基于鸽巢原理,如果我们对N个数据求哈希值,就必然会存在哈希值相同的情况
  • 一般情况下,哈希值越长的哈希算法,散列冲突的概率越低
6.4.2.2 唯一标识
  • 如果要在海量的图库中,搜索一张图是否存在
  • 我们可以给每一个图片取一个唯一标识,或者说信息摘要。比如,我们可以从图片的二进制码串开头取 100 个字节,从中间取 100 个字节,从最后再取 100 个字节,然后将这 300 个字节放到一块,通过哈希算法(比如 MD5),得到一个哈希字符串,用它作为图片的唯一标识
6.4.2.3 数据校验
  • 用同一hash算法(比如MD5),对文件取哈希值并进行对比,防止数据被篡改
6.4.2.4 散列/哈希 函数
  • HashMap
6.4.2.5 负载均衡
  • 负载均衡算法有很多,比如轮询、随机、加权轮询
  • 如何才能实现一个会话粘滞(session sticky)的负载均衡算法呢?也就是说,我们需要在同一个客户端上,在一次会话中的所有请求都路由到同一个服务器上
  • 可以通过哈希算法,对客户端 IP 地址或者会话 ID 计算哈希值,将取得的哈希值与服务器列表的大小进行取模运算,最终得到的值就是应该被路由到的服务器编号
6.4.2.6 数据分片
6.4.2.6.1 如何统计“搜索关键词”出现的次数?
  • 假如我们有 1T 的日志文件,这里面记录了用户的搜索关键词,我们想要快速统计出每个关键词被搜索的次数,该怎么做呢?
  • 我们可以先对数据进行分片,然后采用多台机器处理的方法,来提高处理速度
  • 具体的思路是这样的:为了提高处理的速度,我们用 n 台机器并行处理。我们从搜索记录的日志文件中,依次读出每个搜索关键词,并且通过哈希函数计算哈希值,然后再跟 n 取模,最终得到的值,就是应该被分配到的机器编
  • 这样,哈希值相同的搜索关键词就被分配到了同一个机器上。也就是说,同一个搜索关键词会被分配到同一个机器上。每个机器会分别计算关键词出现的次数,最后合并起来就是最终的结果。
6.4.2.7 分布式存储
  • 现在互联网面对的都是海量的数据、海量的用户。我们为了提高数据的读取、写入能力,一般都采用分布式的方式来存储数据,比如分布式缓存
  • 该如何决定将哪个数据放到哪个机器上呢?我们可以借用前面数据分片的思想,即通过哈希算法对数据取哈希值,然后对机器个数取模,这个最终值就是应该存储的缓存机器编号
  • 但是要扩容时,会有问题,由于机器增多,所有的数据都要重新计算哈希值,然后重新搬移到正确的机器上。这样就相当于,缓存中的数据一下子就都失效了。所有的数据请求都会穿透缓存,直接去请求数据库 — 缓存雪崩
6.4.2.7.1 一致性哈希算法
  • 假设我们有 k 个机器,数据的哈希值的范围是[0, MAX]。
  • 我们将整个范围划分成 m 个小区间(m 远大于 k),每个机器负责 m/k 个小区间。
  • 当有新机器加入的时候,我们就将某几个小区间的数据,从原来的机器中搬移到新的机器中。
  • 这样,既不用全部重新哈希、搬移数据,也保持了各个机器上数据数量的均衡
  • 具体可参考:漫画一致性哈希算法

6.5 思考题

6.5.1 为什么哈希表和链表经常一块使用?

  • 哈希表这种数据结构虽然支持非常高效的数据插入、删除、查找操作,但是哈希表中的数据都是通过哈希函数打乱之后无规律存储的。也就说,它无法支持按照某种顺序快速地遍历数据
  • 如果希望按照顺序遍历哈希表中的数据,那我们需要将哈希表中的数据拷贝到数组中,然后排序,再遍历
  • 而且哈希表是动态数据结构,不停地有数据的插入、删除,所以每当我们希望按顺序遍历散列表中的数据的时候,都需要先排序,那效率势必会很低
  • 为了解决顺序快速地遍历数据效率低的问题,故经常将散列表和链表(或者跳表)结合在一起使用

6.5.2 假设我们有 10 万条 URL 访问日志,如何按照访问次数给 URL 排序?

  • 遍历 10 万条数据,以 URL 为 key,访问次数为 value,存入散列表,同时记录下访问次数的最大值 K,时间复杂度 O(N)
  • 如果 K 不是很大,可以使用桶排序,时间复杂度 O(N)。
  • 如果 K 非常大(比如大于 10 万),就使用快速排序,复杂度 O(NlogN)

6.5.3 有两个字符串数组,每个数组大约有 10万条字符串,如何快速找出两个数组中相同的字符串?

  • 以第一个字符串数组构建散列表,key 为字符串,value 为出现次数。
  • 再遍历第二个字符串数组,以字符串为 key 在散列表中查找,如果 value 大于零,说明存在相同字符串。
  • 时间复杂度 O(N)

7 树/二叉树

7.1 树

Alt text

  • A 节点就是 B 节点的父节点,B 节点是 A 节点的子节点。B、C、D 这三个节点的父节点是同一个节点,所以它们之间互称为兄弟节点
  • 根节点:把没有父节点的节点,比如图中的 E
  • 叶子节点/叶节点:我们把没有子节点的节点,比如图中的 G、H、I、J、K、L
  • 节点高度:节点到叶子节点的最长路径(边数)
  • 节点深度:根节点到这个节点所经历的的边的个数
  • 节点层数:节点深度 + 1
  • 树的高度:根节点的高度

Alt text

7.2 二叉树

  • 每个节点最多有两个“叉”,也就是两个子节点,分别是左子节点右子节点

7.2.1 满二叉树

  • 叶子节点全都在最底层,除了叶子节点之外,每个节点都有左右两个子节点
  • 下图中的2

Alt text

7.2.2 完全二叉树

  • 叶子节点都在最底下两层,最后一层的叶子节点都靠左排列(从 左数到右是连续,中间没有断开,缺少节点),并且除了最后一层,其他层的节点个数都要达到最大
  • 比如上图的 编号为3的树,以及下图中的树

Alt text

7.2.3 二叉树的存储

7.2.3.1 链式存储法
  • 大部分二叉树代码都是通过这种结构来实现的

Alt text

7.2.3.2 基于数组的顺序存储法
  • 适合完全二叉树
  • 我们把根节点存储在下标 i = 1 的位置,那左子节点存储在下标 2 * i = 2 的位置,右子节点存储在 2 * i + 1 = 3 的位置。以此类推,B 节点的左子节点存储在 2 * i = 2 * 2 = 4 的位置,右子节点存储在 2 * i + 1 = 2 * 2 + 1 = 5 的位置。

Alt text

  • 节点 X 存储在数组中下标为 i 的位置,下标为 2 * i 的位置存储的就是左子节点,下标为 2 * i + 1 的位置存储的就是右子节点
  • 反过来,下标为 i/2 的位置存储就是它的父节点
  • 如果某棵二叉树是一棵完全二叉树,那用数组存储无疑是最节省内存的一种方式
  • 因为数组的存储方式并不需要像链式存储法那样,要存储额外的左右子节点的指针。这也是为什么完全二叉树会单独拎出来的原因,也是为什么完全二叉树要求最后一层的子节点都靠左的原因

7.2.4 二叉树的遍历

  • 时间复杂度是 O(n)
  • 经典的方法有三种,前序遍历、中序遍历和后序遍历。其中,前、中、后序,表示的是节点与它的左右子树节点遍历打印的先后顺序
7.2.4.1 前序遍历
  • 对于树中的任意节点来说,先打印这个节点,然后再打印它的左子树,最后打印它的右子树
//伪代码实现
//递归
//递推公式
preOrder(r) = print r->preOrder(r->left)->preOrder(r->right)

void preOrder(Node* root) { 
    if (root == null) {
        return; 
    }
    print root; // 此处为伪代码,表示打印root节点 
    preOrder(root->left); 
    preOrder(root->right);
    
}
7.2.4.2 中序遍历
  • 对于树中的任意节点来说,先打印它的左子树,然后再打印这个节点,最后打印它的右子树
  • 先打印根节点,然后再递归地打印左子树,最后递归地打印右子树
//伪代码实现
//递归
//递推公式
inOrder(r) = inOrder(r->left)->print r->inOrder(r-right)

void inOrder(Node* root) { 
    if (root == null) {
        return; 
    }
    inOrder(root->left); 
    print root; // 此处为伪代码,表示打印root节点 
    inOrder(root->right);
    
}
7.2.4.3 后序遍历
  • 对于树中的任意节点来说,先打印它的左子树,然后再打印右子树,最后打印这个节点
//伪代码实现
//递归
//递推公式
postOrder(r) = postOrder(r->left)->postOrder(r-right)->print r

void inOrder(Node* root) { 
    if (root == null) {
        return; 
    }
    postOrder(root->left); 
    postOrder(root->right);
    print root; // 此处为伪代码,表示打印root节点 
    
}
7.2.4.4 层序遍历(按层遍历)
  • 借用队列辅助即可,根节点先入队列,然后循环从队列中pop节点,将pop出来的节点的左子节点先入队列,右节点后入队列,依次循环,直到队列为空,遍历结束
Java代码如下:
/**
 * Definition for a binary tree node.
 * public class TreeNode {
 * int val;
 * TreeNode left;
 * TreeNode right;
 * TreeNode(int x) { val = x; }
 * }
 */
class Solution {
    public List<List<Integer>> levelOrder(TreeNode root) {
        if (root == null) return new ArrayList<>(0);
        
        List<List<Integer>> result = new ArrayList<>();
        
        Queue<TreeNode> queue = new LinkedList<TreeNode>();
        queue.offer(root); 
        
        Queue<TreeNode> curLevelNodes = new LinkedList<TreeNode>();
        
        while (!queue.isEmpty()) {
            TreeNode node = queue.poll();
            curLevelNodes.offer(node);
            
            if (queue.isEmpty()) {
                List<Integer> list = new ArrayList<>(curLevelNodes.size());
                while (!curLevelNodes.isEmpty()) {
                    TreeNode curNode = curLevelNodes.poll();
                    list.add(curNode.val);
                    
                    if (curNode.left != null) {
                        queue.offer(curNode.left); 
                    }
                    
                    if (curNode.right != null) {
                        queue.offer(curNode.right);
                    }
                    
                }
                result.add(list);
            }
        }
        
        
        return result;
    }
    
}

7.2.5 二叉查找树 / 二叉搜索树 / 二叉排序树

  • 支持动态数据集合的快速插入、删除、查找操作
  • 在树中的任意一个节点,其左子树中的每个节点的值,都要小于这个节点的值,而右子树节点的值都大于这个节点的值
  • 中序遍历二叉查找树,可以输出有序的数据序列,时间复杂度是 O(n),非常高效

Alt text

7.2.5.1 查找操作
  • 先取根节点,如果它等于我们要查找的数据,那就返回
  • 如果要查找的数据比根节点的值小,那就在左子树中递归查找
  • 如果要查找的数据比根节点的值大,那就在右子树中递归查找

Alt text

public static class Node {
    private int data;
    private Node left;
    private Node right;
    
    public Node (int data) {
        this.data = data;
    }
}

public class BinarySearchTree {
    
    // tree 为根节点
    public Node find (Node tree,int data) {
        Node p = tree;
        while (p != null) {
            if (data < p.data) {
                p = p.left;
            } else if (data > p.data) {
                p = p.right;
            } else {
                return p;
            }
        }
        return null;
    }
}
7.5.2.2 插入操作
  • 新插入的数据一般都是在叶子节点上,所以我们只需要从根节点开始,依次比较要插入的数据和节点的大小关系
  • 如果要插入的数据比节点的数据大,并且节点的右子树为空,就将新数据直接插到右子节点的位置;如果不为空,就再递归遍历右子树,查找插入位置。
  • 同理,如果要插入的数据比节点数值小,并且节点的左子树为空,就将新数据插入到左子节点的位置;如果不为空,就再递归遍历左子树,查找插入位置

Alt text

public static class Node {
    private int data;
    private Node left;
    private Node right;
    
    public Node (int data) {
        this.data = data;
    }
}

public class BinarySearchTree {

    // tree 为根节点
    public void  insert (Node tree,int data) {
        if (tree == null) {
            tree = new Node(data);
            return;
        }
        Node p = tree;
        while (p != null) {
            if (data < p.data) {
                if (p.left == null) {
                    p.left = new Node(data);
                    return;
                }
                p = p.left;
            } else if (data > p.data) {
                if (p.right == null) {
                    p.right = new Node(data);
                    return;
                }
                p = p.right;
            } 
        }
    }
}
7.5.2.3 删除操作
  • 第一种情况: 如果要删除的节点没有子节点,我们只需要直接将父节点中,指向要删除节点的指针置为 null
  • 第二种情况: 如果要删除的节点只有一个子节点(只有左子节点或者右子节点),我们只需要更新父节点,让它指向要删除节点的子节点就可以了
  • 第三种情况: 如果要删除的节点有两个子节点,这就比较复杂了。我们需要找到这个节点的右子树中的最小节点,把它替换到要删除的节点上。然后再删除掉这个最小节点,因为最小节点肯定没有左子节点(如果有左子结点,那就不是最小节点了),所以,我们可以应用上面两条规则来删除这个最小节点
public static class Node {
    private int data;
    private Node left;
    private Node right;
    
    public Node (int data) {
        this.data = data;
    }
    
    // tree 为根节点
    public void  delete (Node tree,int data) {
        //第一步:首先找出要删除的节点
        
        //p 指向要删除的节点,初始化指向根节点
        Node p = tree;
        //记录P的父节点,初始化为null
        Node pp = null;
        
        while (p != null && p.data != data) {
            pp = p;
            if (data > p.data) {
                p = p.right
            } else {
                p = p.left;
            }
        }
        if (p == null) {
            return; //没有找到
        }
        
        //要删除的节点有两个子节点
        //查找右子树中的最小节点
        if (p.left != null && p.right != null) {
            Node minP = p.right;
            Node minPP = p; // minPP表示minP的父节点
            while (minP.left != null) {
                minPP = minP;
                minP = p.left;
            }
            // 将minP的数据替换到p中
            p.data = minP.data;
            // 下面就变成了删除minP了
            p = minP;
            pp = minPP;
        }
        
        //第二步:开始删除找到的节点
        
        //如果删除的节点是叶子节点,或者 仅有一个子节点
        Node child; //p 的子节点
        if (p.left != null) {
            child = p.left;
        } else if (p.right != null) {
            child = p.right;
        } else {
            child = null;
        }
        
        if(pp == null) {
            tree = child; //如果删除的是根节点
        } else if (pp.left == p) {
            pp.left = child;
        } else {
            pp.right = child;
        }
    } 
}

7.5.2.4 有了散列/哈希表,并且其动态查询、插入、删除的时间复杂度为O(1),相对而言二叉查找树这些操作的时间复杂度最好才为O(logn),那为什么还要用二叉查找树?
  • 散列表中的数据是无序存储的,如果要输出有序的数据,需要先进行排序。而对于二叉查找树来说,我们只需要中序遍历,就可以在 O(n) 的时间复杂度内,输出有序的数据序列
  • 散列表扩容耗时很多,而且当遇到散列冲突时,性能不稳定,尽管二叉查找树的性能不稳定,但是在工程中,我们最常用的平衡二叉查找树的性能非常稳定,时间复杂度稳定在 O(logn)
  • 笼统地来说,尽管散列表的查找等操作的时间复杂度是常量级的,但因为哈希冲突的存在,这个常量不一定比 logn 小,所以实际的查找速度可能不一定比 O(logn) 快。加上哈希函数的耗时,也不一定就比平衡二叉查找树的效率高
  • 散列表的构造比二叉查找树要复杂,需要考虑的东西很多。比如散列函数的设计、冲突解决办法、扩容、缩容等。平衡二叉查找树只需要考虑平衡性这一个问题,而且这个问题的解决方案比较成熟、固定
7.5.2.5 求一棵给定二叉树的高度
  • 思路一:递归,根节点高度=max(左子树高度,右子树高度)+1
  • 思路二:采用层次遍历的方式
  • 每一层记录都记录下当前队列的长度,这个是队尾,每一层队头从0开始。然后每遍历一个元素,队头下标+1。直到队头下标等于队尾下标。这个时候表示当前层遍历完成。每一层刚开始遍历的时候,树的高度+1。最后队列为空,就能得到树的高度

7.2.6 平衡二叉查找树

  • 二叉查找树在频繁的动态更新过程中,可能会出现树的高度远大于 log2n 的情况,从而导致各个操作的效率下降。极端情况下,二叉树会退化为链表,时间复杂度会退化到 O(n)
  • 要解决这个复杂度退化的问题,我们需要设计一种平衡二叉查找树
  • 严格定义:二叉树中任意一个节点的左右子树的高度相差不能大于 1
  • 但实际使用中,并不一定按严格定义来,只要树的高度不比 log2n 大很多(比如树的高度仍然是对数量级的),我们仍然可以说,这是一个合格的平衡二叉查找树
  • 平衡二叉查找树中“平衡”的意思,其实就是让整棵树左右看起来比较“对称”、比较“平衡”,不要出现左子树很高、右子树很矮的情况。这样就能让整棵树的高度相对来说低一些,相应的插入、删除、查找等操作的效率高一些
7.2.6.1 红黑树
  • 近似平衡(从根到叶子节点的最长路径不会超过最短路径的2倍,是一种自动平衡的二叉查找树
  • 根节点是黑色的;
  • 每个叶子节点都是黑色的空节点(NIL),也就是说,叶子节点不存储数据
  • 任何相邻的节点都不能同时为红色,也就是说,红色节点是被黑色节点隔开的
  • 每个节点,从该节点到达其可达叶子节点的所有路径,都包含相同数目的黑色节点
  • 红黑树的插入、删除、查找各种操作性能都比较稳定,时间复杂度都是 O(logn)
  • 参考文章:清晰理解红黑树的演变—红黑的含义
  • 参考文章:漫画算法:什么是红黑树?
7.2.6.1.1 2-3树
  • 是二叉查找树的变种,树中的2和3代表两种节点
  • 2-节点即普通节点:包含一个元素,两条子链接

Alt text

  • 3-节点则是扩充版,包含2个元素和三条链接:两个元素A、B,左边的链接指向小于A的节点,中间的链接指向介于A、B值之间的节点,右边的链接指向大于B的节点

Alt text

  • 在这两种节点的配合下,2-3树可以保证在插入值过程中,任意叶子节点到根节点的距离都是相同的
7.2.6.1.1.1 2-3树 插入过程
  • 如果将值插入一个2-节点,则将2-节点扩充为一个3-节点
  • 如果将值插入一个3-节点,分以下几种情况
  • (1).3-节点没有父节点,即整棵树就只有它一个三节点。此时,将3-节点扩充为一个4-节点,即包含三个元素的节点,然后将其分解,变成一棵二叉树,此时二叉树依然保持平衡

Alt text

  • (2).3-节点有一个2-节点的父节点,此时的操作是,3-节点扩充为4-节点,然后分解4-节点,然后将分解后的新树的父节点融入到2-节点的父节点中去

Alt text

  • (3).3-节点有一个3-节点的父节点,此时操作是:3-节点扩充为4-节点,然后分解4-节点,新树父节点向上融合,上面的3-节点继续扩充,融合,分解,新树继续向上融合,直到父节点为2-节点为止,如果向上到根节点都是3-节点,将根节点扩充为4-节点,然后分解为新树,至此,整个树增加一层,仍然保持平衡。

Alt text

7.2.6.1.2 红黑树 与 2-3树
  • 红黑树的背后逻辑就是2-3树的逻辑
  • 将3-节点的两个元素用左斜红色的链接连接起来,即连接了两个2-节点来表示一个3-节点
  • 红色节点标记就代表指向其的链接是红链接,黑色标记的节点就是普通的节点
  • 红色节点是可以与其父节点合并为一个3-节点的,红黑树实现的其实是一个完美的黑色平衡,如果你将红黑树中所有的红色链接放平,那么它所有的叶子节点到根节点的距离都是相同的

Alt text

  • 红链接放平(红链接均为左链接)

Alt text

7.2.6.2 几种动态数据结构的对比
  • 动态数据结构是支持动态的更新操作,里面存储的数据是时刻在变化的,通俗一点讲,它不仅仅支持查询,还支持删除、插入数据
  • 而且,这些操作都非常高效。如果不高效,也就算不上是有效的动态数据结构了
  • 数据结构优点缺点应用场景
    哈希表插入删除查找都是O(1), 是最常用的哈希冲突,不能顺序遍历以及扩容缩容的性能损耗那些不需要顺序遍历,海量数据随机访问、防止重复、缓存等
    跳表插入删除查找都是O(logn), 并且能顺序遍历,区间查找非常方便(基于链表),支持多写多读空间复杂度O(n)不那么在意内存空间的
    红黑树插入删除查找都是O(logn), 中序遍历即是顺序遍历,稳定难以实现,去查找不方便TreeMap、TreeSet、HashMap等
  • 相比跳表,红黑树除了内存占用较小,其他性能并不比跳表更优。但由于历史原因,红黑树使用的更广泛

7.2.7 堆和堆排序

  • 堆是一个完全二叉树
  • 堆中每一个节点的值都必须大于等于(或小于等于)其子树中每个节点的值
  • 大顶堆:每一个节点的值都大于等于左右子节点
  • 小顶堆:每一个节点的值都小于等于左右子节点
7.2.7.1 如何实现一个堆 (以大顶堆为例)
  • 一般用数组(i = 0,置空,不存数据)存储堆 (因为堆是一棵完全二叉树)
  • 数组中下标为 i 的节点的左子节点,就是下标为 i∗2 的节点,右子节点就是下标为 i∗2+1 的节点父节点就是下标为 i/2 的节点
  • 如果从 0 开始存储,节点的下标是 i,那左子节点的下标就是 2∗i+1,右子节点的下标就是 2∗i+2,父节点的下标就是 (i-1) / 2 (计算左子节点时,会多一次加法运算
7.2.7.1.1 插入一个元素
  • 堆化:顺着节点所在的路径,向上或者向下,对比,然后交换
  • 让新插入的节点与父节点对比大小。如果不满足子节点小于等于父节点的大小关系,我们就互换两个节点。一直重复这个过程,直到父子节点之间满足刚说的那种大小关系
  • 从下往上 堆化 比如插入 22

Alt text

public class Heap {
    private int[] a; //数组,从下标1开始存储数据
    private int capacity; //堆的容量
    private int count; //堆中已存储的数据个数
    
    public Heap (int capacity) {
        a = new int[capacity + 1];
        this.capacity = capacity;
        count = 0;
    }
    
    public void insert (int data) {
        if (count >= capacity) { //堆满了,无法添加数据
            return;
        }
        count++;
        a[count] = data;
        int i = count;
        
        //自下往上堆化
        while (i/2 > 0 && a[i] > a[i/2]) { // i/2 为i这个节点的父节点
            //交互节点i 与其 父节点的值
            swap(a,i,i/2);
            i = i/2;
        }
    }
    
    private void swap (int[] a,int before,int after) {
        if (a == null || before >= a.length || after >= a.length) {
            return;
        }
        int temp = a[before];
        a[before] = a[after];
        a[after] = temp;
    }
}
  • 时间复杂度为O(logn)
7.2.7.1.2 删除堆顶元素
  • 堆顶元素存储的是堆中的最大值或者最小值
  • 当我们删除堆顶元素之后,就需要把第二大的元素放到堆顶,那第二大元素肯定会出现在左右子节点中。然后我们再迭代地删除第二大节点,以此类推,直到叶子节点被删除(不过这种方法有点问题,就是最后堆化出来的堆并不满足完全二叉树的特性
  • 从上往下 堆化 把最后一个节点放到堆顶,然后利用同样的父子节点对比方法。对于不满足父子节点大小关系的,互换两个节点,并且重复进行这个过程,直到父子节点之间满足大小关系为止

Alt text

public class Heap {
    private int[] a; //数组,从下标1开始存储数据
    private int capacity; //堆的容量
    private int count; //堆中已存储的数据个数
    
    public Heap (int capacity) {
        a = new int[capacity + 1];
        this.capacity = capacity;
        count = 0;
    }
    
    public void remove () {
        if (count == 0) { //堆中没数据
            return;
        }
        
        //自上往下堆化
        a[1] = a[count];
        count--;
        int i = 1;
        while (true) {
            int maxPos = i; //默认i位置为最大元素的位置
            //如果最大元素小于当前节点的左子节点,那么最大元素的位置为左子节点位置
            if (i * 2 <= count && a[i] < a[2*i]) {
                maxPos = i*2;
            }
            //如果最大元素小于当前节点的右子节点,那么最大元素的位置为右子节点位置
            if (i * 2 + 1 <= count && a[maxPos] < a[2*i + 1]) {
                maxPos = i*2 + 1;
            } 
            //如果最大元素为当前节点,就跳出循环
            if (maxPos == i) {
                break;
            }
            //交互当前节点和最大节点的值
            swap(a,i,maxPos);
            
            //将当前节点赋值为最大节点
            i = maxPos;
        }
    }
    
    private void swap (int[] a,int before,int after) {
        if (a == null || before >= a.length || after >= a.length) {
            return;
        }
        int temp = a[before];
        a[before] = a[after];
        a[after] = temp;
    }
}
  • 时间复杂度为O(logn)
7.2.7.2 如何基于堆实现排序?(堆排序)
  • 时间复杂度非常稳定,是 O(nlogn)
  • 并且它还是原地排序算法
7.2.7.2.1 建堆
  • 将数组原地建成一个堆
  • 从下往上堆化,类试于插入数据
  • 从上往下堆化(对下标从 n/2 开始到 1 的数据进行堆化,下标是 n/2 +1 到 n 的节点是叶子节点,不需要堆化)
private static void buildHeap(int[] a, int n) {
  for (int i = n/2; i >= 1; --i) {
    heapify(a, n, i);
  }
}

private static void heapify(int[] a, int n, int i) {
  while (true) {
    int maxPos = i;
    if (i*2 <= n && a[i] < a[i*2]) maxPos = i*2;
    if (i*2+1 <= n && a[maxPos] < a[i*2+1]) maxPos = i*2+1;
    if (maxPos == i) break;
    swap(a, i, maxPos);
    i = maxPos;
  }
}
  • 时间复杂度为O(n)
7.2.7.2.2 排序
  • 建堆结束之后,数组中的数据已经是按照大顶堆的特性来组织的。
  • 数组中的第一个元素就是堆顶,也就是最大的元素。我们把它跟最后一个元素交换,那最大元素就放到了下标为 n 的位置
  • 然后再通过堆化的方法,将剩下的 n−1 个元素重新构建成堆。堆化完成之后,我们再取堆顶的元素,放到下标是 n−1 的位置,一直重复这个过程,直到最后堆中只剩下标为 1 的一个元素,排序工作就完成了
// n表示数据的个数,数组a中的数据从下标1到n的位置。
public static void sort(int[] a, int n) {
  buildHeap(a, n); //堆化 见上面代码
  int k = n;
  while (k > 1) {
    swap(a, 1, k);
    --k;
    heapify(a, k, 1); //从上往下堆化
  }
}
  • 排序复杂度 O(nlogn)
7.2.7.3 在实际开发中,为什么快速排序要比堆排序性能好?
7.2.7.3.1 堆排序数据访问的方式没有快速排序友好
  • 快速排序来说,数据是顺序访问的,而对于堆排序来说,数据是跳着访问的,这样对CPU缓存不友好
7.2.7.3.2 对于同样的数据,在排序过程中,堆排序算法的数据交换次数要多于快速排序
  • 因为每次堆化,反而会使数据更无序,增加了逆序度
7.2.7.4 堆的应用场景
7.2.7.4.1 利用堆求 Top K
  • 1.如何在一个包含 n 个数据的数组中,查找前 K 大数据呢?
  • 可以维护一个大小为 K的小顶堆,顺序遍历数组,从数组中取出数据与堆顶元素比较。
  • 如果比堆顶元素大,我们就把堆顶元素删除,并且将这个元素插入到堆中;
  • 如果比堆顶元素小,则不做处理,继续遍历数组。
  • 这样等数组中的数据都遍历完之后,堆中的数据就是前 K 大数据了
  • 遍历数组需要 O(n) 的时间复杂度,一次堆化操作需要 O(logK) 的时间复杂度,所以最坏情况下,n 个元素都入堆一次,时间复杂度就是 O(nlogK)
  • 2.求实时topK?
  • 可以一直都维护一个 K大小的小顶堆,当有数据被添加到集合中时,我们就拿它与堆顶的元素对比。
  • 如果比堆顶元素大,我们就把堆顶元素删除,并且将这个元素插入到堆中;
  • 如果比堆顶元素小,则不做处理。
  • 这样,无论任何时候需要查询当前的前 K 大数据,我们都可以立刻返回
  • 3.一个包含 10 亿个搜索关键词的日志文件,如何快速获取到 Top 10 最热门的搜索关键词呢?(单机,可使用内存为1G)
  • 思路:就是利用哈希表统计出相同关键词出现的频率,然后利用堆,求Top 10;
  • 创建 10 个空文件 00,01,02,……,09。我们遍历这10亿个关键词,并且通过某个哈希算法对其求哈希值,然后哈希值同10取模,得到的结果就是这个搜索关键词应该被分到的文件编号
  • 对这 10 亿个关键词分片之后,每个文件都只有1亿的关键词,去除掉重复的,可能就只有 1000 万个,每个关键词平均 50 个字节,所以总的大小就是 500MB。1GB 的内存完全可以放得下
  • 我们针对每个包含 1 亿条搜索关键词的文件,利用散列表和堆,分别求出 Top 10,然后把这个 10 个 Top 10 放在一块,然后取这 100 个关键词中,出现次数最多的 10 个关键词,这就是这 10 亿数据中的 Top 10 最频繁的搜索关键词了
7.2.7.4.2 优先队列
  • 1.根据不同优先级来处理网络请求
  • 2.合并有序小文件
  • 假设我们有 100 个小文件,每个文件的大小是 100MB,每个文件中存储的都是有序的字符串。我们希望将这些 100 个小文件合并成一个有序的大文件
  • 利用数组: 从这 100 个文件中,各取第一个字符串,放入数组中,然后比较大小,把最小的那个字符串放入合并后的大文件中,并从数组中删除,依次类推,直到所有的文件中的数据都放入到大文件为止。(每次都要循环数组,时间复杂度为O(n) 不是很高效)
  • 利用堆: 将从小文件中取出来的字符串放入到小顶堆中,那堆顶的元素,也就是优先级队列队首的元素,就是最小的字符串
  • 将这个字符串放入到大文件中,并将其从堆中删除。然后再从小文件中取出下一个字符串,放入到堆中。循环这个过程,就可以将 100 个小文件中的数据依次放入到大文件中。(堆的插入和删除时间复杂度都为O(logn),更加高效)
  • 3.高性能定时器
7.2.7.4.3 求动态数据集合中的中位数
  • 需要维护两个堆,一个大顶堆,一个小顶堆。
  • 大顶堆中存储前半部分数据,小顶堆中存储后半部分数据,且小顶堆中的数据都大于大顶堆中的数据
  • n 是偶数,那前 n/2 个数据存储在大顶堆中,后 n/2 个数据存储在小顶堆中
  • n 是奇数,情况是类似的,那前 n/2 + 1 个数据存储在大顶堆中,后 n/2 个数据存储在小顶堆中
  • 新进元素值大于等于小顶堆堆顶元素的,插入小顶堆,否则插入大顶堆。
  • 但由于动态添加,有可能不满足这个关系,可以从一个堆中不停地将堆顶元素移动到另一个堆,通过这样的调整,来让两个堆中的数据满足上面的约定
  • 这样,大顶堆中的堆顶元素就是我们要找的中位数

8. 图

  • 图中的元素我们就叫作顶点(vertex)
  • 图中的一个顶点可以与任意其他顶点建立连接关系。我们把这种建立的关系叫作边(edge)
  • 度:表示一个顶点有多少条边
  • 边没有有方向的图叫作 “无向图”

Alt text

  • 边有方向的图叫作 “有向图”

Alt text

  • 每条边都有一个权重叫做 "带权图"

Alt text

8.1 图的存储

8.1.1 邻接矩阵

  • 邻接矩阵的底层依赖一个二维数组
  • 对于无向图来说,如果顶点 i 与顶点 j 之间有边,我们就将 A[i][j]和 A[j][i]标记为 1;
  • 对于有向图来说,如果顶点 i 到顶点 j 之间,有一条箭头从顶点 i 指向顶点 j 的边,那我们就将 A[i][j]标记为 1;同理,如果有一条箭头从顶点 j 指向顶点 i 的边,我们就将 A[j][i]标记为 1
  • 带权图,数组中就存储相应的权重

Alt text

  • 优点:简单、直观、基于数组和矩阵,方便计算,时间上相对高效
  • 缺点:比较浪费存储空间

8.1.1 邻接表

  • 每个顶点对应一条链表,链表中存储的是与这个顶点相连接的其他顶点

Alt text

  • 优点:比较节省空间
  • 缺点:计算和使用 不是很高效
8.1.1.1 代码实现
public class Graph { // 无向图
  private int v; // 顶点的个数
  private LinkedList<Integer> adj[]; // 邻接表

  public Graph(int v) {
    this.v = v;
    adj = new LinkedList[v];
    for (int i=0; i<v; ++i) {
      adj[i] = new LinkedList<>();
    }
  }

  public void addEdge(int s, int t) { // 无向图一条边存两次
    adj[s].add(t);
    adj[t].add(s);
  }
}

8.2 搜索算法

  • 基本是基于“图”
  • 图上的搜索算法,最直接的理解就是,在图中找出从一个顶点出发,到另一个顶点的路径

8.2.1 广度优先搜索(BFS)

  • 它其实就是一种 “地毯式”层层推进的搜索策略,即先查找离起始顶点最近的,然后是次近的,依次往外搜索

Alt text

  • 时间复杂度:O(E) E为边的条数
  • 空间复杂度:O(V) V为顶点的个数
  • 仅适用于状态空间不大,也就是说图不大的搜索

8.2.2 深度优先搜索(DFS)

  • 类似 “走迷宫”
  • 假设你站在迷宫的某个岔路口,然后想找到出口。你随意选择一个岔路口来走,走着走着发现走不通的时候,你就回退到上一个岔路口,重新选择一条路继续走,直到最终找到出口。这种走法就是一种深度优先搜索策略

Alt text

  • 时间复杂度:O(E) E为边的条数
  • 空间复杂度:O(V) V为顶点的个数
  • 仅适用于状态空间不大,也就是说图不大的搜索
  • 用的是回溯思想,非常适合用递归实现

9. 字符串匹配

  • 主串:当前的字符串本身
  • 模式串:被查找/匹配的字符串
  • 在字符串 A 中查找字符串 B,那字符串 A 就是主串,字符串 B 就是模式串
  • 主串的长度为n,模式串的长度为m

9.1 BF算法

  • 暴力匹配算法,也叫朴素匹配算法
  • 我们在主串中,检查起始位置分别是 0、1、2…n-m 且长度为 m 的 n-m+1 个子串,看有没有跟模式串匹配的
  • 时间复杂度:O(n*m)

9.1.1 为什么BF 算法的时间复杂度很高,是 O(n*m),但在实际的开发中,但它却是一个比较常用的字符串匹配算法?

  • 实际的软件开发中,大部分情况下,模式串和主串的长度都不会太长。而且每次模式串与主串中的子串匹配的时候,当中途遇到不能匹配的字符的时候,就可以就停止了,不需要把 m 个字符都比对一下。大部分情况下,算法执行效率要比O(n*m)高很多
  • BF算法思想简单,代码实现也非常简单。简单意味着不容易出错,如果有 bug 也容易暴露和修复。在工程中,在满足性能要求的前提下,简单是首选

9.2 RK算法

  • 全称叫 Rabin-Karp 算法,是由它的两位发明者 Rabin 和 Karp 的名字来命名的
  • 其实就是 BF 算法的升级版
  • 算法思路:通过哈希算法对主串中的 n-m+1 个子串分别求哈希值,然后逐个与模式串的哈希值比较大小。如果某个子串的哈希值与模式串相等,那就说明对应的子串和模式串匹配了(当有哈希冲突时,再对比一下子串和模式串本身就好了)。因为哈希值是一个数字,数字之间比较是否相等是非常快速的,所以模式串和子串比较的效率就提高了

9.3 BM算法

  • 一种非常高效的字符串匹配算法
  • 核心思想:当遇到不匹配的字符时,BF 算法和 RK 算法的做法是,模式串往后滑动一位;BM 算法 能够跳过一些肯定不会匹配的情况,将模式串往后多滑动几位

9.3.1 坏字符规则

  • 从模式串的末尾往前倒着匹配,当我们发现某个字符没法匹配的时候。我们把这个没有匹配的字符叫作坏字符(主串中字符)
  • 当发生不匹配的时候,我们把坏字符对应的模式串中的字符下标记作 si。
  • 如果坏字符在模式串中存在,我们把这个坏字符在模式串中的下标记作 xi。如果不存在,我们把 xi 记作 -1。那模式串往后移动的位数就等于 si-xi。(注意,这里说的下标,都是字符在模式串的下标

Alt text

  • BM 算法在最好情况下的时间复杂度非常低,是 O(n/m)
  • 不过,单纯使用坏字符规则还是不够的。因为根据 si-xi 计算出来的移动位数,有可能是负数,比如主串是 aaaaaaaaaaaaaaaa,模式串是 baaa

9.3.2 好后缀规则

Alt text

  • 把已经匹配的 bc 叫作好后缀,记作{u}。我们拿它在模式串中查找,如果找到了另一个跟{u}相匹配的子串{u*},那我们就将模式串滑动到子串{u*}与主串中{u}对齐的位置

Alt text

  • 当模式串中不存在等于{u}的子串时,不仅要看好后缀在模式串中,是否有另一个匹配的子串,我们还要考察好后缀的后缀子串,是否存在跟模式串的前缀子串匹配的
  • 所谓某个字符串 s 的后缀子串,就是最后一个字符跟 s 对齐的子串,比如 abc 的后缀子串就包括 c, bc。所谓前缀子串,就是起始字符跟 s 对齐的子串,比如 abc 的前缀子串有 a,ab
  • 从好后缀的后缀子串中,找一个最长的并且能跟模式串的前缀子串匹配的,假设是{v},然后将模式串滑动到如图所示的位置

Alt text

10 算法思想

10.1 贪心算法

  • 不要刻意去记忆贪心算法的原理,多练习才是最有效的学习方法
  • 贪心算法的本质就是"在满足限制条件下,只考虑当前最优的步骤,而不顾全大局"
  • 基本上可以用贪心算法解决的问题:针对一组数据,我们定义了限制值和期望值,希望从中选出几个数据,在满足限制值的情况下,期望值最大
  • 贪心算法得到的结果不一定是最优解

10.1.1 具体应用场景

10.1.1.1 分糖果
  • 我们有 m 个糖果和 n 个孩子。我们现在要把糖果分给这些孩子吃,但是糖果少,孩子多(m<n),所以糖果只能分配给一部分孩子。每个糖果的大小不等,这 m 个糖果的大小分别是 s1,s2,s3,……,sm。除此之外,每个孩子对糖果大小的需求也是不一样的,只有糖果的大小大于等于孩子的对糖果大小的需求的时候,孩子才得到满足。假设这 n 个孩子对糖果大小的需求分别是 g1,g2,g3,……,gn
  • 问题是,如何分配糖果,能尽可能满足最多数量的孩子,一个孩子只能分一个糖果?
  • 贪心算法思想:对于一个孩子来说,如果小的糖果可以满足,我们就没必要用更大的糖果,这样更大的就可以留给其他对糖果大小需求更大的孩子。另一方面,对糖果大小需求小的孩子更容易被满足,所以,我们可以从需求小的孩子开始分配糖果。因为满足一个需求大的孩子跟满足一个需求小的孩子,对我们期望值的贡献是一样的。我们每次从剩下的孩子中,找出对糖果大小需求最小的,然后发给他剩下的糖果中能满足他的最小的糖果,这样得到的分配方案,也就是满足的孩子个数最多的方案
10.1.1.2 钱币找零
  • 假设我们有 1 元、2 元、5 元、10 元、20 元、50 元、100 元这些面额的纸币,它们的张数分别是 c1、c2、c5、c10、c20、c50、c100。我们现在要用这些钱来支付 K 元,最少要用多少张纸币呢?
  • 贪心算法思想:在贡献相同期望值(纸币数目)的情况下,我们希望多贡献点金额,这样就可以让纸币数更少。先用面值最大的来支付,如果不够,就继续用更小一点面值的,以此类推,最后剩下的用 1 元来补齐
10.1.1.3 区间覆盖

10.1.2 相关案例

10.1.2.1 在一个非负整数 a 中,我们希望从中移除 k 个数字,让剩下的数字值最小,如何选择移除哪 k 个数字呢?
  • 由最高位开始,比较低一位数字,如高位大,移除,若高位小,则向右移一位继续比较两个数字,直到高位大于低位则移除,循环k次
  • 比如 4556847594546 移除5位
  • 第一次 455647594546 -> 45547594546 -> 4547594546 -> 447594546 -> 44594546
10.1.2.2 假设有 n 个人等待被服务,但是服务窗口只有一个,每个人需要被服务的时间长度是不同的,如何安排被服务的先后顺序,才能让这 n 个人总的等待时间最短?
  • 想让所有人的等待时间最短,那么我们得先处理服务时间短的,尽快把他们处理完了才能够处理后面的人!

10.2 分治算法

  • 典型应用: MapReduce
  • 核心思想:分而治之,也就是将原问题划分成 n 个规模较小,并且结构与原问题相似的子问题,递归地解决这些子问题,然后再合并其结果,就得到原问题的解
  • 分治算法是一种处理问题的思想,递归是一种编程技巧

10.2.1 能用分治算法解决问题的特征

  • 原问题与分解成的小问题具有相同的模式
  • 原问题分解成的子问题可以独立求解,子问题之间没有相关性,这一点是分治算法跟动态规划的明显区别
  • 具有分解终止条件,也就是说,当问题足够小时,可以直接求解
  • 可以将子问题合并成原问题,而这个合并操作的复杂度不能太高,否则就起不到减小算法总体复杂度的效果了

10.3 回溯算法

  • 回溯的处理思想,有点类似枚举搜索。我们枚举所有的解,找到满足期望的解
  • 都是用来解决广义的搜索问题,也就是,从一组可能的解中,选择出一个满足要求的解
  • 回溯算法本质上就是枚举,优点在于其类似于摸着石头过河的查找策略,且可以通过剪枝少走冤枉路
  • 它可能适合应用于缺乏规律,或我们还不了解其规律的搜索场景中
  • 回溯算法的复杂度比较高,是指数级别的

10.3.1 八皇后问题

  • 有一个 8x8 的棋盘,希望往里放 8 个棋子(皇后),每个棋子所在的行、列、对角线都不能有另一个棋子
public class EightQueens {

    //全局或成员变量,下标表示行,值表示queen存储的列
    int[] result = new int[8];
    
    public void cal8Queens (int row) {
        if (row == 8) {// 8个棋子都放置好了,打印结果
            printQueens(result);
            return; // 8行棋子都放好了,已经没法再往下递归了,所以就return
        }
        for (int column = 0;column < 8;column++) {
            if (isOk(row,column)) {//如果已经放好
                result[row] = column;// 第row行的棋子放到了column列
                cal8Queens(row+1); // 递归考察下一行
            }
        }
    }
    
    //判断 row 行 column 列放置是否合适
    private boolean isOk (int row,int column) {
        int leftup = column - 1;  //左上角对角线
        int rightup = column + 1; //右上角对角线
        
        for (int i = row -1;i >= 0;i--) { //逐行往上考察每一行
            if (result[i] == column) {// 第i行的column列有棋子吗?
                return false;
            }
            if (leftup >= 0) { // 考察左上对角线: 
                if (result[i] == leftup) { //第i行leftup列有棋子吗?
                    return false; 
                }   
            }
            if (rightup < 8) { // 考察右上对角线
                if (result[i] == rightup) { // 第i行rightup列有棋子吗?
                   return false;  
                }
            } 
            leftup--; 
            rightup++;
        }
        return true; 
    }
    

    private void printQueens(int[] result) { // 打印出一个二维矩阵
        for (int row = 0; row < 8; ++row) {
            for (int column = 0; column < 8; ++column) {
                if (result[row] == column) System.out.print("Q ");
                else System.out.print("* ");
            }
            System.out.println();
        }
        System.out.println();
    }
}

10.3.2 0-1背包问题

  • 有一个背包,背包总的承载重量是 Wkg。现在我们有 n 个物品,每个物品的重量不等,并且不可分割。我们现在期望选择几件物品,装载到背包中。在不超过背包所能装载重量的前提下,如何让背包中物品的总重量最大?
public class BagQuestion {
    
    //存储背包中物品总重量的最大值
    public int maxW = Integer.MiN_VALUE;
    
    // countWeight表示当前已经装进去的物品的重量和;i表示考察到哪个物品了;
    // bagWeight表示背包最大乘重;items表示物品的重量数组;n表示物品个数
    // 假设背包可承受重量100,物品个数10,物品重量存储在数组a中,那可以这样调用函数:
    // bugFunction(0, 0, a, 10, 100)
    public void bugFunction (int i,int countWeight,int[] items,int n,int bagWeight) {
        //如果装满了 或者 已经考察完所有物品,则结束
        if (countWeight == bagWeight || i == n) {
            if (countWeight > maxW) {
                maxW = countWeight;
            }
            return;
        }
        //下个物品不放进背包,即不考查
        bugFunction(i+1,countWeight,items,n,bagWeight);
        
        //将下个物品放进背包
        // 已经装好的物品总量不能超过背包的承受重量
        if (countWeight + item[i] <= bagWeight) {
            bugFunction(i+1,countWeight + item[i],items,n,bagWeight);
        }
    }
}

10.4 动态规划

  • 把问题分解为多个阶段,每个阶段对应一个决策。
  • 记录每一个阶段可达的状态集合(去掉重复的),然后通过当前阶段的状态集合,来推导下一个阶段的状态集合,动态地往前推进
  • 比较适合用来求解最优问题,比如求最大值、最小值等等

10.4.1 什么样的问题适合用动态规划来解决

  • "一个模型三个特征"
10.4.1.1 多阶段决策最优解模型
  • 一般是用动态规划来解决最优问题
  • 解决问题的过程,需要经历多个决策阶段
  • 每个决策阶段都对应着一组状态
  • 然后我们寻找一组决策序列,经过这组决策序列,能够产生最终期望求解的最优值
10.4.1.2 最优子结构
  • 最优子结构指的是,问题的最优解包含子问题的最优解
  • 可以通过子问题的最优解,推导出问题的最优解
  • 也可以理解为,后面阶段的状态可以通过前面阶段的状态推导出来
10.4.1.3 无后效性
  • 第一层含义是,在推导后面阶段的状态的时候,我们只关心前面阶段的状态值,不关心这个状态是怎么一步一步推导出来的
  • 第二层含义是,某阶段状态一旦确定,就不受之后阶段的决策影响
  • 只要满足前面提到的动态规划问题模型,其实基本上都会满足无后效性
10.4.1.4 重复子问题
  • 不同的决策序列,到达某个相同的阶段时,可能会产生重复的状态

10.4.2 动态规划解题思路

10.4.2.1 状态转移表法
  • 一般能用动态规划解决的问题,都可以使用回溯算法的暴力搜索解决
  • 所以,当我们拿到问题的时候,我们可以先用简单的回溯算法解决,然后定义状态,每个状态表示一个节点,然后对应画出递归树。
  • 从递归树中,我们很容易可以看出来,是否存在重复子问题,以及重复子问题是如何产生的。
  • 以此来寻找规律,看是否能用动态规划解决
  • 先画出一个状态表。状态表一般都是二维的,所以你可以把它想象成二维数组。
  • 其中,每个状态包含三个变量,行、列、数组值
  • 我们根据决策的先后过程,从前往后,根据递推关系,分阶段填充状态表中的每个状态。最后,我们将这个递推填表的过程,翻译成代码,就是动态规划代码了
  • 如果是高维的,就不适合用状态转移表法来解决了
  • 回溯算法实现 - 定义状态 - 画递归树 - 找重复子问题 - 画状态转移表 - 根据递推关系填表 - 将填表过程翻译成代码
10.4.2.2 状态转移方程法
  • 类似递归的解题思路
  • 根据最优子结构,写出递归公式,也就是所谓的状态转移方程,是解决动态规划的关键
  • 找最优子结构 - 写状态转移方程 - 将状态转移方程翻译成代码

10.4.3 四种算法的比较分析

  • 贪心、回溯、动态规划可以归为一类,都可以抽象成多阶段决策最优解模型;分治算法单独可以作为一类
  • 回溯算法是个“万金油”(穷举搜索)。基本上能用的动态规划、贪心解决的问题,我们都可以用回溯算法解决,不过,回溯算法的时间复杂度非常高,是指数级别的,只能用来解决小规模数据的问题
  • 能用动态规划解决的问题,需要满足三个特征,最优子结构、无后效性和重复子问题而分治算法要求分割成的子问题,不能有重复子问题
  • 贪心算法实际上是动态规划算法的一种特殊情况。它能解决的问题需要满足三个条件,最优子结构、无后效性和贪心选择性(这里我们不怎么强调重复子问题)
  • “贪心选择性”的意思是,通过局部最优的选择,能产生全局的最优选择。每一个阶段,我们都选择当前看起来最优的决策,所有阶段的决策完成之后,最终由这些局部最优解构成全局最优解。
  • 3
    点赞
  • 14
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值