0%

子图

有两个图G=(V,E)和G’=(V’,E’),V’是V的子集且E’是E的子集,称G’是G的子图。

并非所有V和E的子集都能构成G的子图,因为这样的子集可能不是图。

连通

无向图中顶点u到顶点v之间有一条路径,称顶点u和顶点v是连通的。

强连通

有向图中顶点u到顶点v之间有一条路径,顶点v到顶点u之间也有一条路径,称顶点u和顶点v是强连通的。

弱连通

有向图中顶点u到顶点v之间有一条路径,顶点v到顶点u之间没有路径,称顶点u和顶点v是弱连通的

连通图

无向图中任意两个顶点连通,称此无向图为连通图。

强连通图

有向图中任意两个顶点强连通,称此有向图为强连通图。

弱连通图

有向图的边去掉方向后是一个连通图,称此有向图为弱连通图。

非连通图

一个图的顶点数为n,若边数小于n-1,则该图是非连通图,即图的所有边无法连接图中所有的顶点。

非连通图

连通子图

在非连通图中,存在至少两个连通的子图。

极大连通子图(针对无向图)

非连通图中的连通子图称为极大连通子图。

加入任何一个不在它的点集中的点都会导致它不再连通,极小连通子图似乎也有这个性质?

无向连通图只有一个极大连通子图,就是本身。

极小连通子图(针对无向图)

保持子图连通但边数最少。

若极小连通子图有n个顶点,则其有n - 1条边。

极大强连通子图(针对有向图)

子图中的每两个顶点都是强连通的。

推论:一个环肯定是极大强连通子图,每两个顶点互相都有路径到达。

连通分量(针对无向图)

无向图中的极大连通子图称为无向图的连通分量。

无向连通图只有一个连通分量,为本身。

强连通分量(针对有向图)

有向图的极大强连通子图。

割点

对于一个无向图,如果把一个点删除后这个图的极大连通分量数增加了,那么这个点就是这个图的割点(又称割顶)。

来自 <https://oi-wiki.org/graph/cut/>

桥(割边)

对于一个无向图,如果删掉一条边后图中的连通分量数增加了,则称这条边为桥或者割边。

来自 <https://oi-wiki.org/graph/cut/>

tarjan算法

dfn[u]是图的dfs节点访问次序,dfn意为depth first number

low[u]是默认为dfn[u],如果dfs时通过一条边访问到了已访问的节点,选取已访问节点中的最小的dfn值

low数组在另一个意义上可以看作,把有环的部分给标记出来了,环上的节点low值都一样

参考资料:

tarjan算法有什么应用场景?

  1. 求图的割点
  2. 求图的割边(桥)
  3. 判断图的连通性
  4. 判断图有没有环

tarjan算法为什么可以判断图的割点和割边?

判断一个顶点是不是割点除了从定义,还可以从DFS(深度优先遍历)的角度出发。我们先通过DFS定义两个概念。

假设DFS中我们从顶点U访问到了顶点V(此时顶点V还未被访问过),那么我们称顶点U为顶点V的父顶点,V为U的孩子顶点。在顶点U之前被访问过的顶点,我们就称之为U的祖先顶点。

显然如果顶点U的所有孩子顶点可以不通过父顶点U而访问到U的祖先顶点,那么说明此时去掉顶点U不影响图的连通性,U就不是割点。相反,如果顶点U至少存在一个孩子顶点,必须通过父顶点U才能访问到U的祖先顶点,那么去掉顶点U后,顶点U的祖先顶点和孩子顶点就不连通了,说明U是一个割点。

来自:Tarjan算法:求解图的割点与桥(割边)

tarjan算法在LeetCode中的可以应用的题目?

普通的二叉查找树存在什么问题?平衡二叉查找树解决了什么问题?

普通的二叉查找树最坏情况下会退化为一个链表,查找元素的时间复杂度由log_2{n}退化到n。

解决时间复杂度退化,就是要让树的高度始终保持尽可能的小,平衡二叉查找树就是让任意节点的左右子树高度都平衡(差的不多)的二叉查找树,这样查找元素的平均时间复杂度可以保持在log_2{n},

有哪些平衡二叉查找树?

AVL树、2-3树、红黑树

为什么流行的主要是这几个树?

因为逻辑比较简单,易于实现,效率也就高。

AVL树特点

任意结点左右子树高度差小于或等于1。

由Adelson-Velskii 以及 Landis发明,故而叫AVL。

2-3树

定义:

  • 在满足二叉查找树的性质基础上,一个结点最多可以存储2个键,可以有3个孩子结点。
  • 2结点里存储1个键,可以有2个孩子结点
  • 3结点里存储2个键,可以有3个孩子结点

特点

  • 2-3树是完美平衡的,任意结点的左右子树高度相等,这可以分析所有插入的情况来证明。

自下而上生长:

  • 插入位置在2结点中,直接插入,变为3结点。
  • 插入位置在3结点中,先插入键变成4结点,再把中间的键提升到父结点中,当前结点就变成了两个2结点(不直接把键插入父结点是因为插入的键和提升到父结点的键不一定是同一个)。
    • 如果父结点本来是2结点,现在就变成了3结点。
    • 如果父结点本来是3结点,现在就变成了4结点,继续提升的过程。
    • 如果没有父结点,当前即为根结点,那么就把临时的4结点分解为3个2结点,中间的键提升为2结点作为新的根结点。

在根结点为3结点时,插入新键变为4结点,再拆分为3个2结点后,树的高度才会加1。

一颗含有n个节点的2-3树的高度在log_3{n}(全是3结点)到log_2{n}(全是2结点)之间。

每次插入后调整结点都是局部的,最坏情况下,一条路径上都是3结点,从叶结点插入新键后,会从叶结点一直调整到根结点,调整次数不会超过对数级别。

10亿个结点的2-3树高度仅在19到30之间,性能较高。

各种操作实现较为复杂。

为什么2-3树可以保持完美平衡?

这可以分析所有插入的情况来证明。

自下而上生长

  1. 插入位置在2结点中,直接插入,变为3结点

  2. 插入位置在3结点中,先插入键变成4结点,再把中间的键提升到父结点中,当前结点就变成了两个2结点(不直接把键插入父结点是因为插入的键和提升到父结点的键不一定是同一个)

  3. 如果父结点本来是2结点,现在就变成了3结点

  4. 如果父结点本来是3结点,现在就变成了4结点,继续提升的过程

  5. 如果没有父结点,当前即为根结点,那么就把临时的4结点分解为3个2结点,中间的键提升为2结点作为新的根结点

在根结点为3结点时,插入新键变为4结点,再拆分为3个2结点后,树的高度才会加1

红黑树

实现较为简单,综合性能好。

把2-3树的3结点表示为左斜的红色链接相连的两个2结点,其中一个结点是另一个结点的左子结点,其他链接为黑色链接。

红黑树既是二叉查找树也是2-3树。

等价定义(《算法》第4版 275页 3.3.2.1):

  1. 红色链接均为左链接
  2. 没有任何一个结点同时和两条红链接相连
  3. 该树是完美黑色平衡的,即任意空链接到根结点的路径上的黑链接数量相同

红黑树的几条定义都是为了让红黑树满足2-3树的结构。

如果让2-3树中所有链接都为黑色,由于2-3树是完美平衡的,红黑树中红色链接代表3结点,所以红黑树中的黑色链接是完美平衡的。

再把2-3树中的3结点都分解为两个用红色链接相连的2结点,那么不会存在一个结点同时与两条红色链接相连,这一点保证了红色链接是3结点。

对红黑树插入、删除元素后,不满足红黑树的定义,都要通过旋转操作来修正。

红黑树的旋转操作修正,都是符合2-3树的修正的规则的,理解记住了2-3树的各种调整规则,就知道了红黑树各种操作的意义。

红黑树是怎么发明出来的?

它在1972年由鲁道夫·贝尔发明,被称为”对称二叉B树”,它现代的名字源于Leo J. Guibas和Robert Sedgewick1978年写的一篇论文。

红黑树等同于2-3-4树,是2-3-4树的二叉表现形式。

2-3-4树是B树的一种情况。

红黑树结点的颜色表明了当前结点是否属于2-3-4树中的3结点或4结点。

2-3-4树的情况讨论比较复杂, 用2-3树讨论情况较少,可以方便理解。


红黑树定义

《算法》第4版 275页 3.3.2.1 定义:

  1. 红色链接均为左链接
  2. 没有任何一个结点同时和两条红链接相连
  3. 该树是完美黑色平衡的,即任意空链接到根结点的路径上的黑链接数量相同

《算法导论》273页 第13章 红黑树 定义:

  1. 节点是红色或黑色
  2. 根节点是黑色
  3. 叶节点是黑色
  4. 红节点的两个子节点都是黑色
  5. 任意节点到叶节点的所有路径上的黑色节点数相同

红黑树为什么要这样定义?

红黑树的几条定义都是为了让红黑树满足2-3树的结构,红黑树是2-3树的一种表示形式,而2-3树是完美平衡的,这样红黑树也是黑色平衡的,2-3树比较难以实现,红黑树比较方便实现。

为什么要用红黑树表示2-3树?

  • 因为2-3树是完美平衡的,任意结点的子树没有高度差。
  • 2-3树的实现较为复杂,红黑树的实现较为简单。

红黑树的数据结构如何定义?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
/**
* 树的结点
*/
data class Node<Key : Comparable<Key>, Value>(
/**
* 父结点指向本结点的链接颜色,用以标明本结点对应2-3树中的2结点还是3结点
*/
var color: Color,
/**
* 关键字。用于比较来确定数据的顺序
*/
val key: Key,
/**
* 关联的数据
*/
var value: Value,
/**
* 左子结点
*/
var left: Node<Key, Value>? = null,
/**
* 右子结点
*/
var right: Node<Key, Value>? = null
)

/**
* 链接颜色
*/
enum class Color {
BLACK, RED
}

红黑树插入新元素后调整结点的操作规则是怎样的?

原则:所有操作跟2-3树能逐一对应,保证树的有序性和完美平衡性。

插入新结点先按普通的二叉查找树插入新结点那样进行插入。

普通的二叉查找树插入新元素有三种情况:

  1. 树中没有结点,插入的新结点作为根结点
  2. 树中有结点,插入的新结点一定是某个结点h的左子结点
  3. 树中有结点,插入的新结点一定是某个结点h的右子结点

在红黑树中,结点h又可以分为两种情况

  1. 结点h是2结点
  2. 结点h位于3结点中
    1. 结点h是红链接左下的结点
    2. 结点h是红链接右上的结点

插入新结点要默认新结点的颜色是红色,表示新结点到父节点的链接是红色,这样与2-3树的插入就可以对应上。

在2-3树中如果插入在2结点中,2结点变为3结点,就不再调整;如果插入的在3结点中,3结点临时变为4结点,再把4结点的中间键放入父结点中,左右两边的键分解为两个2结点与父结点相连。

处理这些情况的组合,就是所有的插入情况,针对不符合2-3树的结构的情况进行调整。

  • 结点h是2结点
    • 新结点是h的左子结点,相当于新结点和结点h组成了2-3树中的3结点,符合2-3树的结构,无需调整
    • 新结点是h的右子结点,相当于新结点和结点h组成了2-3树中的3结点,但我们规定红链接要保持左斜,以减少考虑的情况数量,所以要进行左旋转
  • 结点h位于3结点中,新结点的位置有三种情况:左、中、右
    • 结点h是红链接左下的结点
      • 新结点是h的左子结点,此时有了两条连续的红链接,连接的3个结点对应2-3树中临时的4结点,需要把中间的键放入父结点,再把两边的键拆为两个2结点分别与父结点相连,所以操作是先右旋,再变换颜色
      • 新结点是h的右子结点
    • 结点h是红链接右上的结点
      • 新结点是h的右子结点

有三种标准操作:左旋、右旋、颜色转换

为什么红黑树中红链接只能保持左斜?为什么不能有右斜的红链接?

只允许红色的左链接可以减少讨论的情景数量,进而简化代码的实现。

红黑树最坏情况下高度是多少?

有n个结点的红黑树高度最多为 $2 * log_2{n}$

由于2-3树中的3结点是由左斜红链接连接的两个结点表示的,最坏情况下最左侧路径全部都是红链接,对应2-3树中最左侧都是3结点,其他结点都是2结点。

参考《算法导论》中的证明:
红黑树相关定理及其证明

红黑树的各种操作的时间复杂度是多少?

设红黑树有n个结点,查找、插入、修改、删除操作的时间复杂度均为O(log n)

查找:

  • 最长路径长度不会超过最短路径长度的2倍,查找仍然是对数级别

插入:

  • 最坏情况下待插入的位置在叶结点,需要从根结点遍历到叶结点,同时最坏情况下遍历的路径都是红链接(路径上都是3结点),此时会从叶结点一直回溯调整结点到根结点,访问次数是2倍的树的高度,而树的高度为log_2{n}

红黑树较于AVL树有什么优点?

单次操作:

  • 单次插入数据,红黑树和AVL树平均都只需要几次旋转即可完成。
  • 单次删除元素,红黑树大多数只需要几次旋转调整,AVL树需要调整查找路径上所有结点。

大量的插入和删除操作:

  • 红黑树由于是基于2-3树,3结点的存在可以吸纳一部分不平衡性,不用做频繁的旋转调整。
  • AVL树需要始终保持平衡,在大量插入或删除元素后,AVL树的调整的次数多于红黑树。

在大量数据的统计意义上,红黑树的插入和删除次数相对于AVL树较少,适合插入和删除频繁的场景。

AVL树由于是高度平衡的,红黑树不是高度平衡,AVL树的查找次数比红黑树更少,适合对查询效率非常敏感,插入和删除不频繁的场景。

红黑树的查询跟AVL树仍然是同一个数量级,所以综合查询、插入、删除的性能,红黑树表现较好。

参考:

什么时候用AVL树?

AVL树平衡性非常好,左右子树高度差不超过1,所以查找次数少。

对于查找非常频繁,插入、修改、删除不频繁的场景,可以使用AVL树。

并发下的问题

并发情况下,由于平衡搜索树的调整可能要锁整个树。

用跳表这种性能接近于平衡树的数据结构,操作更加局部性,不会锁住太多结点,有利于并发的性能。

参考资料

LZ77和哈夫曼编码区别?适用场景上有有什么不同?

哈夫曼编码是基于统计的数据压缩编码,需要先获得信息源的字符出现频率,然后再进行压缩。

但是,如果信息源是流式传输的,就没办法预先做统计,需要换一种思路。

LZ77利用数据的重复结构信息来进行数据压缩,是基于字典的压缩算法,可以做流逝压缩。

GZIP压缩的过程就是先用LZ77算法进行流式压缩,再对结果做哈夫曼编码压缩。

为什么叫LZ77?

由以色列的两位大神Jacob Ziv与Abraham Lempel在1977年发表的论文《A Universal Algorithm for Sequential Data Compression》中提出。

LZ77算法思想概述

核心思想:利用短语表示数据的重复结构信息来进行数据压缩。

LZ77算法一般称为“滑动窗口压缩”,算法的核心是在前面的历史数据中寻找重复字符串。

通过滑动窗口实现动态字典,用前面出现过的字符串作为字典通过映射(与前一个字符串的距离和字符串长度)替代后面重复出现的字符串。

重复现象是具有局部性的,它的基本假设是,如果一个字符串要重复,那么也是在附近重复,远的地方就不用找了,因此设置了一个滑动窗口。

其方式就是把数据中一些可以组织成短语(最长字符)的字符加入字典,然后再有相同字符出现采用标记来代替字典中的短语,如此通过标记代替多数重复出现的方式以进行压缩。

滑动窗口越大,压缩的效果越好,因为编码的短语越多,但是压缩速度越慢,因为要计算的短语数量越多。

基于字典是什么意思?

滑动窗口内的字符都是已经出现过的字符,已经出现过的字符会编码为字典短语,后面前向缓冲窗口中的字符如果和字典中的短语相同,就用距离和字符串长度来表示,以达到压缩的目的。

LZ77压缩效果

大多数情况下LZ77压缩算法的压缩比相当高。

实际压缩率和选择的滑动窗口大小、前向缓冲区大小、数据熵有关系。

LZ77缺点

压缩过程是比较耗时,因为要花费很多时间寻找滑动窗口中的短语匹配。

不过解压过程很快,因为每个标记都明确告知在哪个位置可以读取了。

什么特征的文本用LZ77压缩效果好?

压缩就是用更短的符号来表示重复出现的字符串。

压缩就是寻找文本的内容分布概率,将出现频率高的部分代替成更短的形式。

内容越是重复,就可以压缩的更小。

内容如果毫无重复,就很难压缩。

LZ77详细原理

参见: Linux(程序设计):28—数据流压缩原理(Deflate压缩算法、gzip、zlib)

为什么GZIP在用LZ77压缩过后还要进行哈夫曼编码?

LZ77编码后得到的是,距离(distance)和长度(length),还有未匹配到短语字典的原始字符(literal)。

比较短的距离和长度可能是频繁出现的,就可以用变长编码来压缩,且文本已经确定下来不会变动,可以做词频统计,就可以用到哈夫曼编码了。

参考资料

什么是哈夫曼编码

出现频次越高的字符,编码长度越小。

哈夫曼编码的价值

变长编码,使得编码的平均长度最短,实现压缩率大的无损压缩。

因为哈夫曼树是最优的,每次的选择都是贪心选择,这个局部最优也是全局最优。

哈夫曼编码过程

  • 把文本中字符按出现的频次排序。
  • 每个字符作为一个结点放入一个集合。
  • 取集合最小的两个频次的结点,为左右子结点,生成一个父结点,父结点的频次是两个子结点频次之和,把父结点再加入到集合中。
  • 重复这个构造过程,生成一个最优二叉树。
  • 给二叉树所有左边设置0,所有右边设置1。
  • 字符都在叶子结点。
  • 根结点到叶子结点路径上的0和1组成的码字就是该字符的编码。

因为所有字符都出现在叶子结点,保证了哈夫曼编码当中的任何一个字符的编码都不能是另一个字符编码的前缀。也就是说哈夫曼编码是一种前缀编码。


如何证明哈夫曼编码是最优的?

哈夫曼编码过程每次的选择都是贪心选择,这个局部最优也是全局最优。

其正确性证明依赖于贪心选择性质和最优子结构。


哈夫曼编码的特点

  1. 编码非等长
  2. 编码前缀不重复

哈夫曼编码的压缩效果?

哈夫曼编码可以很有效的压缩数据,具体压缩率依赖于数据本身的特性。

齐夫定律:

发现某一单词出现的频率与其在频率表里名次的常数次幂成反比,也就是说极少数的单词会被经常使用,而绝大多数单词很少被提及,这种20/80法则在很多领域都被逐步发现,这种幂律分布被称为“齐夫定律”(Zipf’s law)

一般信息的分配都是幂率分布。

所以用哈夫曼编码压缩的比例一般都挺高的,70%以上。

信息压缩的极限在哪?

香农第一定理给出了无损的情况下数据压缩的临界值。

参考:信息论入门教程

冒泡排序

一句话描述

左侧无序区域中的最大数字交换到右侧已排序区域的最左侧。

冒泡排序代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
class BubbleSort {
fun sort(nums: IntArray) {
val n = nums.size
for (i in 0 until n) {
// nums[0, n - 1 - i]是无序区域,初始时整个数组无序
// nums[n - i,n - 1]是有序区域,是数组中大的数

// 记录一趟冒泡是否有交换发生
var isNotSwapped = true

// 因为要比较当前和下一个数大小,所以j只能取到n - 1 - i的前一个数
for (j in 0 until n - 1 - i) {
// 不是升序,就要交换一下
if (nums[j] > nums[j + 1]) {
// 交换
val tmp = nums[j + 1]
nums[j + 1] = nums[j]
nums[j] = tmp
// 标记有交换
isNotSwapped = false
}
}

// 没有交换,说明无序区域已经有序,那么整个数组也是有序的了
if (isNotSwapped) break
}
}
}

稳定性

冒泡排序是稳定的

因为交换是当前数大于下一个数才会交换。

如果有x1 == x2x1x2左边,x1始终不会被交换到x2右边。

选择排序

一句话描述

右侧未排序区域选取一个最小的数,交换到前面已排序区域的末尾。

选择排序代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class SelectionSort {  
fun sort(nums: IntArray) {
val n = nums.size
for (i in 0 until n) {
// s[0,i-1]为已排序区域,s[i,n-1]为未排序区域
// 从未排序取一个最小的放入已排序区域的末尾
var minIndex = i
for (j in i + 1 until n) {
if (nums[j] < nums[minIndex]) {
minIndex = j
}
}
// 将未排序区域中最小值与已排序区域末尾的下一个位置的数字交换
// 已排序区域末尾的下一个位置就是i了,最小值索引是minIndex
val tmp = nums[minIndex]
nums[minIndex] = nums[i]
nums[i] = tmp
}
}
}

稳定性

选择排序不稳定,因为发生了位置交换。

由于每次会在后面的未排序区域选择最小的数字与前面的已排序区域末尾元素交换,如果未排序区域交换的位置的前面有与已排序区域末尾元素相等的元素,这两个元素的相对位置就变了。

例如[2, 3, 4, 2, 1],第一趟选择会把最小的1放到最前面,第一个2交换到最后面,这样两个2的相对顺序就变了。

如何不发生位置交换呢?

有两种做法:

  • 一个是开辟一个新数组,把最小的放到第一个位置上,把第二小的放到第二个位置上等等。空间复杂度是O(n)。
  • 一个是使用链表,空间复杂度是O(1)。

特点

交换次数比冒泡排序少,交换次数是跟数组长度呈线性关系。

直接插入排序

一句话描述

扑克牌拿牌后插牌的排序:把数组右侧未排序区域的最左侧元素插入数组左侧已排序区域中。

直接插入排序代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class InsertSort {  
fun sort(nums: IntArray) {
val n = nums.size
// 初始时未排序区域共有n个数,每次向左侧已排序区域插入一个数,总共需要插入n次
for (i in 0 until n) {
// nums[0,i-1]为已排序区域
// nums[i,n-1]为未排序区域
// 从未排序区域第一个元素开始,从后往前一个个跟已排序区域数字比较,相邻两个数字顺序不对就交换,直至顺序正确
// 由于要比较当前数字和前一个数字的大小,所以索引j最少只能取到第2个元素位置即索引1
for (j in i downTo 1) {
// 如果相邻两个数不是升序则需要交换
if (nums[j] < nums[j - 1]) {
// 交换元素
val tmp = nums[j]
nums[j] = nums[j - 1]
nums[j - 1] = tmp
break
}
}
}
}
}

优化

已排序区域寻找插入位置可以使用二分查找。

适用场景

适合部分有序的数组,这样比较次数就会大大减少,从而提高效率。

对于大规模乱序的数组,插入排序很慢,因为只会交换相邻的元素,元素只能一步一步的从数组的一端移动到另一端。

例如,如果是升序排序,数组最小的元素在数组末尾,那么移动到开头就要交换N-1次。如果有一个完全降序的数组,用插入排序变为升序的话,要做的事情太多了。

给定一个10万个元素的数组,部分有序,部分无序,选择哪一种排序算法最好?

用插入排序,插入排序在已排序区域寻找插入位置可以用二分法加快寻找

希尔排序

一句话描述

先大步再小步的插入排序。

大步插入排序使得用很少的交换次数让数组变得部分有序,从而在小步排序时发挥插入排序的优势,达到总体的比较和交换次数变少。

希尔排序代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class ShellSort {
fun sort(nums: IntArray) {
val n = nums.size
var d = 1
while (d < n / 3) {
d = 3 * d + 1
}
while (d >= 1) {
for (i in d until n) {
for (j in i downTo d step d) {
if (nums[j] < nums[j - d]) {
val tmp = nums[j]
nums[j] = nums[j - d]
nums[j - d] = tmp
break
}
}
}
d /= 3
}
}
}

递增序列如何选择?不同的递增序列有什么影响?

《算法(第4版)》

如何选择递增序列呢?要回答这个问题并不简单。算法的性能不仅取决于h,还取决于h 之间的数学性质,比如它们的公因子等。

有很多论文研究了各种不同的递增序列,但都无法证明某个序列是 “ 最好的” 。

算法2.3中递增序列的计算和使用都很简单,和复杂递增序列的性能接近。但可以证明复杂的序列在最坏情况下的性能要好于我们所使用的递增序列。更加优秀的递增序列有待我们去发现。

一个简单的序列选择:
从1开始,一直 d * 3 + 1,直到小于 n / 3。

希尔排序更高效的原因?

希尔排序权衡了数组的规模和有序性。

排序之初,各个子数组都很短,排序之后子数组都是部分有序的,这两种情况都很适合插入排序。

子数组部分有序的程度取决于递增序列的选择。

适用场景

在大规模乱序的情况下,希尔排序可以减少元素交换的次数,数组越大优势越大。

快速排序

一句话

选取一个轴心元素,将数组划分成比这个数小的和比这个数字大的两个子数组,分别对两个子数组递归调用划分。

快速排序代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
fun sort(nums: IntArray) {
if (nums.isEmpty()) {
return
}
return quickSort(nums, 0, nums.size - 1)
}

fun quickSort(nums: IntArray, low: Int, high: Int) {
if (nums.isEmpty()) {
return
}
if (low < high) {
val pivotIndex = partition(nums, low, high)
quickSort(nums, low, pivotIndex - 1)
quickSort(nums, pivotIndex + 1, high)
}
}

fun partition(nums: IntArray, low: Int, high: Int): Int {
var start = low
var end = high
val pivot = nums[start]
while (start < end) {
while (start < end && nums[end] >= pivot) {
end--
}
nums[start] = nums[end]
while (start < end && nums[start] <= pivot) {
start++
}
nums[end] = nums[start]
}
nums[start] = pivot
return start
}

为什么partition()里的while判断条件里low不能等于high?

因为一开始取pivot就已经挖空了一个位置。

特点

每次划分,轴心元素就在最终排序完成后的位置上。

快排为什么是O(n log n)复杂度?

根据主定理推导。

参考: 如何证明快速排序法的平均复杂度为 O(nlogn)? - 知乎

什么会影响时间复杂度?

用于划分数组的中枢元素的选择会影响时间复杂度,划分的左右子数组数量越接近效果越好,否则会让整个快速排序退化到O(n^2)级别。

具体怎么划分要根据数组本身的数据分布特性来决定

以下情况会变得低效:

(1)近乎有序的数列

对于一个近乎有序的数列,当直接使用第一个元素作为基准点的时候,将会导致划分的子数组大小差距太大,进而无法发挥快排划分的优势

(2)含有大量重复数据的数列

選取的數字如果是重複較多的數字,划分出的两个子数组有一边的长度会很大,因为移动指针的时候,判断条件是大于等于和小于等于枢纽元素

如何优化时间复杂度?

针对近乎有序的数组:

三数取中法

选取基准点之前我们可以拿出数列中间位置元素的值,将它和首尾的元素进行比较,之后将这三个数中的中间大的数交换到数列首位的位置,之后将这个数作为基准点,尽量减小之后的分区后左右两边的区间长度之差。

LeetCode.75.颜色分类(中等)正好就是用到这种方法。

随机交换法

选取基准点之前设计随机种子,通过随机函数得到一个在数列长度内的数,将这个随机数作为索引所指的数和第一个元素进行交换,之后将首位元素作为基准点。即随机选一个数放到首位的地方。这样一来,第一次就将最小的数交换到首位的概率是非常小的,第二次将次小的数交换到首位的概率依然非常的小。

堆排序

一句话说明

堆就是一个完全二叉树,堆排序两步走:
建堆:从最后一个非叶子节点到根结点不停的向下调整堆。
排序调整:堆顶元素与数组末尾元素交换,再向下调整堆顶元素。

堆排序代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62

class HeapSort {
fun sort(nums: IntArray) {
// 构建最大堆
buildHeap(nums)
// 堆顶最大的元素与数组末尾的数字进行交换
// 堆大小减1
// 新的堆顶元素可能破坏最大堆性质,需要向下调整,把缩小后的堆的最大的元素放到堆顶
// 重复如此最后堆大小缩减为0,原数组从末尾开始向前填充大的数,最后得到升序数组
for (length in nums.size downTo 1) {
nums.swap(0, length - 1)
sink(nums, 1, length - 1)
}
}

private fun buildHeap(nums: IntArray) {
val n = nums.size
// 给数组元素从1到n编号,最后一个非叶节点的编号为n/2
// 从最后一个非叶节点开始往前不停的向下调整堆
// 如果一个结点的左右子树已经是堆,从该结点向下调整后该结点为根结点的二叉树依然保持着堆的性质
// 所以可以从下往上不停的向下调整
for (parent in n / 2 downTo 1) {
sink(nums, parent, n)
}
}

/**
* 对[nums]的第[k]个元素为根结点的子堆进行向下调整,把大的元素放到堆顶
*
* 要从[k]向下调整是因为[k]与孩子结点可能不满足堆性质
* 初始时已经建立了堆,交换前我们可以认定k的左右子树都已经满足最大堆的性质,即k的左右子结点一定比它下面的所有结点值都大
* 如果k当前比左右孩子最大的一个要小,当把k的左右孩子结点与k交换,依然满足k大于所有其孩子结点
*/
private fun sink(nums: IntArray, k: Int, length: Int) {
var parent = k
// 最后一个非叶节点编号是length/2
// parent初始时是在上层的结点,一直会往下遍历,一直遍历到最后一个非叶节点
while (parent <= length / 2) {
// 找出较小的孩子结点child
var child = parent * 2
// 如果child不是最后一个元素,对比下其相邻的孩子谁更大,取更大的孩子结点,以便接下来跟其父结点parent比较,检查是否满足最大堆的性质
if (child < length && nums[child - 1] < nums[child]) child++
// 因为初始已经建了最大堆,我们可以认定parent的左右子树都已经满足堆的性质
// 如果当前parent与child也满足堆性质,则不用继续调整了
// 这里构建的是大顶堆,要求父结点比孩子结点要大
if (nums[parent - 1] >= nums[child - 1]) break
// 父结点比孩子结点小,不满足最大堆性质,交换父结点和孩子结点的值,以满足最大堆性质
nums.swap(parent - 1, child - 1)
// 交换后,以孩子结点child为根的子堆可能不满足最大堆性质,继续向下检查调整
parent = child
}
}

/**
* 定义数组IntArray的扩展函数swap,用以交换数组内两个位置[i]和[j]的元素
*/
private fun IntArray.swap(i: Int, j: Int) {
val tmp = this[i]
this[i] = this[j]
this[j] = tmp
}
}

堆排序的时间复杂度也是O(n * logn),为什么一般排序用快速排序?

因为堆是一种完全二叉树,访问的数据不在内存中连续的区域,空间访问局部性效果不太好,缓存命中率低,进而降低了程序运行速度。

快速排序会访问数组相邻的元素,空间访问局部性比较好,程序运行速度快。

归并排序

一句话说明

归并两个子数组为一个有序数组。

可以自顶向下递归进行,也可以自底向上迭代进行。

归并排序代码

自顶向下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45

class MergeSort {
fun sort(nums: IntArray) {
mergeSort(nums, 0, nums.size - 1)
}

/**
* 自顶向下,分而治之
* 类似于二叉树后序遍历的写法,理解二叉树后续遍历这个递归写法就好理解对应上了
*/
private fun mergeSort(nums: IntArray, low: Int, high: Int) {
// 只有一个元素的时候,low与high相等,只有一个数字的子数组认定是有序的,不需要再排序了
if (low < high) {
val mid = low + (high - low) / 2
mergeSort(nums, low, mid)
mergeSort(nums, mid + 1, high)
merge(nums, low, mid, high)
}
}

/**
* 归并两个子数组为一个有序数组,用双指针法
*/
private fun merge(nums: IntArray, low: Int, mid: Int, high: Int) {
val copiedNums = nums.copyOf()
// 最终将两个子数组归并的大数组的指针
var p = low
// 划分的第一个(左侧)子数组的指针
var p1 = low
// 划分的第二个(右侧)子数组的指针
var p2 = mid + 1
// 以上三个指针都会从各自数组的起始位置移动到末尾位置
// 开始遍历整个数组
for (i in low..high) {
when {
// 2.当其中一个子数组已经遍历完了,即代表该子数组的元素已经全部复制到大数组,把剩下一个未遍历完的数组的剩余元素在全部复制到大数组的末尾
p1 > mid -> nums[p++] = copiedNums[p2++]
p2 > high -> nums[p++] = copiedNums[p1++]
// 1.一开始会比较个子数组最前端的元素大小,把小的放到最终数组的位置
copiedNums[p1] < copiedNums[p2] -> nums[p++] = copiedNums[p1++]
else -> nums[p++] = copiedNums[p2++]
}
}
}
}

自底向上:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56

class MergeSort {
fun sort(nums: IntArray) {
mergeSort(nums)
}

/**
* 自底向上
*/
private fun mergeSort(nums: IntArray) {
val n = nums.size
// 从最小的子数组开始向上归并,最小的子数组长度是1,每次向上归并后子数组大小变为原来的两倍
var size = 1
while (size < n) {
// 按照子数组的大小将长度为n的数组划分为n/size个子数组
var low = 0
// 依次归并 n/size 个子数组
while (low < n - size) {
// 第一个子数组的右边界索引(包含)
val mid = low + size - 1
// 第二个子数组的右边界索引(包含),最后一个子数组可能只包含的元素个数较少,需要防止数组越界
val high = min(low + 2 * size - 1, n - 1)
// 归并两个子数组
merge(nums, low, mid, high)
// 每次归并2个子数组,所以下一次归并发生在第三个子数组的位置
low += size * 2
}
size *= 2
}
}

/**
* 归并两个子数组为一个有序数组,用双指针法
*/
private fun merge(nums: IntArray, low: Int, mid: Int, high: Int) {
val copiedNums = nums.copyOf()
// 最终将两个子数组归并的大数组的指针
var p = low
// 划分的第一个(左侧)子数组的指针
var p1 = low
// 划分的第二个(右侧)子数组的指针
var p2 = mid + 1
// 以上三个指针都会从各自数组的起始位置移动到末尾位置
// 开始遍历整个数组
for (i in low..high) {
when {
// 2.当其中一个子数组已经遍历完了,即代表该子数组的元素已经全部复制到大数组,把剩下一个未遍历完的数组的剩余元素在全部复制到大数组的末尾
p1 > mid -> nums[p++] = copiedNums[p2++]
p2 > high -> nums[p++] = copiedNums[p1++]
// 1.一开始会比较个子数组最前端的元素大小,把小的放到最终数组的位置
copiedNums[p1] < copiedNums[p2] -> nums[p++] = copiedNums[p1++]
else -> nums[p++] = copiedNums[p2++]
}
}
}
}

计数排序

一句话说明

  1. 记录待排序数组每个取值的个数
  2. 用一个数组累加记录有多少数是小于等于当前索引I
  3. 逆序输出

计数排序代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27

class CountSort {
private val w = 10000

/**
* [nums]取值范围在1到w
*/
fun sort(nums: IntArray) {
val n = nums.size
val copied = nums.copyOf()
val count = IntArray(w + 1) { 0 }
// 统计每个取值有多少个
for (num in nums) {
count[num]++
}
// 累加计数
for (i in 1..w) {
count[i] += count[i - 1]
}
// 逆序遍历原数组,保持元素相对顺序不变
for (i in n - 1 downTo 0) {
val index = count[copied[i]] - 1
nums[index] = copied[i]
count[copied[i]]--
}
}
}

为什么最后要逆序遍历原数组?

  1. 这里必须从后向前遍历,只有这样出现重复的元素,才会保持顺序的把最后面的重复元素,永远放在最右边,从而保证排序的稳定性
  2. 如果从前向后排序,重复元素的顺序,刚好相反,所以就不是稳定的算法,但如果不关注稳定性,那么结果还是正确的

保证相同的数字还是按照原数组中的顺序,保证稳定性

比如[1,2,3,4,5,5,5]

最后从后向前遍历原数组,3个5的输出顺序还是跟原数组的顺序是一致的

如果是从前向后输出,3个5的位置正好倒过来了,因为最终排序的索引是通过计数来得到的,计数是从大到小的,所以最后相同值的索引位置的计算是从大到小的,也就是说相同值的索引位置是从后往前的,如果顺序遍历原数组,遇到几个相同的数字,会先把前面的数字先放到后面了

特点

O(n)时间复杂度

适用场景

数组取值范围是常数范围。

为什么选择ACRA做崩溃收集?

项目主页:https://github.com/ACRA/acra

  1. ACRA历史悠久,github显示最早的4.2.3的版本是2011年7月2日。
  2. Issues里不停的有人报问题,也在不停的发新的release,项目处于积极的持续维护的状态。
  3. 自定义性很强,框架所有功能都是可配置自定义的,也提供了服务端的标准实现。
  4. 使用广泛。ACRA is used in 1.57% (See AppBrain/stats) of all apps on Google Play as of June 2020. That’s over 13 thousand apps and over 5 billion downloads including ACRA.
阅读全文 »

Gson解析在Kotlin下有什么问题?

忽略了data class的null safty检查和默认值。

为什么Gson会忽略data class的null safty检查和默认值?

在Gson的ReflectiveTypeAdapterFactory中调用了ConstructorConstructor.get()。

在这里Gson 实例化对象分为四种情况:

  1. 使用我们自定义的 InstanceCreator,可以在初始化时加入它;
  2. 使用默认构造器,也就是无参构造函数;
  3. 如果是 Collection 或 Map,则返回对应的对象;
  4. 使用 UnSafe。

如果没有无参构造函数就用UnSafe去构造对象。

UnSafe去构造对象,会绕过构造函数,只会在堆中去分配一个对象实例。

如果仅对data class的部分字段设置了默认值,而不是所有字段设置默认,是不会生成无参构造函数的。

解决方案

想办法提供一个无参构造函数。

  1. 构造函数中的所有变量都设置默认值
  2. 手动写一个无参构造函数。
  3. 字段声明在类的内部,就会自动无参构造函数了。

改写data class不太合适,这样的方式不够自动化,无法做出客观的保证。

最好的方式是对Gson做改进,因为引入新的库可能所有序列化的地方都要改。

有哪些库解析data class没有问题?
moshi和kotlinx.serialization。

moshi的解决方案有两种:

  • 引入kotlin反射库,包体积会增大2.5MB。
  • 编译时通过注解处理器为每个data class生成JsonAdapter去解析Json数据。

kotlinx.serialization解决方案:

  • 通过在data class上添加注解,编译时通过注解处理器来处理。

Gson可以解决这些问题吗?
可以把moshi的解决方案在gson里实现一下是比较适合的,改造需要动用不少代码。如果改动太大就是直接用moshi了。

Moshi 和 Kotlin.serialization 的对比:

两者对 Kotlin 的支持差异不大。

  1. KS 的优势是支持 Kotlin 的 Multiplatform,对于需要多平台移植的 Kotlin 代码,使用 KS 更合适,但不兼容Java。
  2. Moshi 的优势是兼容 Java 。

所以如果是 Kotlin 与 Java 混编,考虑使用 Moshi。

参考资料

reified解决的是什么问题?

泛型会在编译后类型擦除,在运行时无法获得泛型类型T的类型信息。

想要在运行时获取泛型类型信息,需要使用reified和inline配合。

Kotlin编译器会将reified方法内联(inline)到调用的地方(call-site)。

方法被内联到调用的地方后,泛型T会被替换成具体的类型。

所以 reified 使得泛型的方法假装在运行时能够获取泛型的类信息。

这样不用为了获取泛型的类型再单独给方法传一个Class参数。

协变是什么意思?

带 extends 限定(上界)的通配符类型使得类型是协变的(covariant)。

逆变是什么意思?

super限定的下界通配符使得类型是逆变的(contravariance)。

在 Java 中有 List<? super String>List<Object>List<String> 的一个超类。

in和out关键字是做什么的?

使用关键字 out 来支持协变,等同于 Java 中的上界通配符 ? extends。

使用关键字 in 来支持逆变,等同于 Java 中的下界通配符 ? super。

上界通配符用于读取,是产出东西给外面用,所以是out。

下界通配符用于修改,是生产东西存到容器里,所以是in。

消费者 in, 生产者 out。

kotlin泛型中的where有什么作用?

Java 中声明类或接口的时候,可以使用 extends 来设置边界,将泛型类型参数限制为某个类型的子集:

1
2
3
// T的类型必须是 Animal 的子类型
class Monster<T extends Animal>{
}

注意这个和前面讲的声明变量时的泛型类型声明是不同的东西,这里并没有 ?。
同时这个边界是可以设置多个,用 & 符号连接:

1
2
3
// T 的类型必须同时是 Animal 和 Food 的子类型
class Monster<T extends Animal & Food>{
}

Kotlin 只是把 extends 换成了 : 冒号。
class Monster<T : Animal>
设置多个边界可以使用 where 关键字:
class Monster<T> where T : Animal, T : Food
有人在看文档的时候觉得这个 where 是个新东西,其实虽然 Java 里没有 where ,但它并没有带来新功能,只是把一个老功能换了个新写法。

不过笔者觉得 Kotlin 里 where 这样的写法可读性更符合英文里的语法,尤其是如果 Monster 本身还有继承的时候:

1
2
class Monster<T> : MonsterParent<T>
where T : Animal

实现一个 fill 函数,传入一个 Array 和一个对象,将对象填充到 Array 中,要求 Array 参数的泛型支持逆变(假设 Array size 为 1)

1
2
3
fun <T> fill(to: Array<in T>, from: T) {
to[0] = from
}

实现一个 copy 函数,传入两个 Array 参数,将一个 Array 中的元素复制到另外个 Array 中,要求 Array 参数的泛型分别支持协变和逆变。

1
2
3
4
5
6
fun <T> copy(from: Array<out T>, to: Array<in T>) {
assert(from.size == to.size)
for (i in from.indices) {
to[i] = from[i]
}
}

inline、noinline、crossline区别?

inline:编译时把inline函数的代码拷贝到调用处

noinline:修饰inline标记的函数的形参,不希望内联lambda

crossline:inline函数中的lambda表达式不允许返回到外部函数,只能返回到lambda表达式

inline作用

Java中没有函数的概念,Kotlin中的lambda表达式在Java中对应的是一个FunctionN的单个方法的接口类,创建一个lambda表达式相当于创建一个类。

inline可以修饰函数,inline修饰的函数的形参中有lambda表达式,编译时不会创建对应的FunctionN类,而是直接把lambda表达式代码拷贝到调用处,这样

  1. 在循环等高频使用一个函数的场景下,内联lambda表达式避免了频繁创建对象,不仅节约了内存,也避免了频繁的垃圾回收,减少系统卡顿
  2. 减少了函数调用层级,函数调用栈少了一层,减少了性能损耗

inline会造成什么问题?

  1. 调用处的代码变多,所以不能内联代码量过大的函数,只适用于内联代码量小的函数
  2. 我们可以在inline函数形参的 lambda 表达式 中调用return直接返回外部函数,可能会导致inline函数之后的代码无法执行;需要使用return@label的语法,返回到lambda开始执行的位置
  3. 内联后的lambda表达式已经不是对象了,所以无法作为参数传递、存储在字段中、作为返回值return,需要加noinline解决

noinline作用

修饰inline函数的形参中的lambda表达式,表示禁止该lambda表达式内联

noinline禁止lambda表达式内联的意义是什么?

内联后的lambda表达式已经不是对象了,所以无法作为对象使用,也就是无法对其进行参数传递、存储在字段中、作为返回值return,需要加noinline解决

crossinline作用

既想让内联函数形参中的 lambda 也被 inline,但是又不想让 lambda 对调用方的控制流程产生影响(lambda中return会影响),就用crossline

crossinline依然是内联的

直接在lambda表达式中返回外部函数的情况称为非局部返回。

crossinline修饰的lambda禁止了非局部返回

crossinline为什么要禁止非局部返回?不禁止会有什么问题?

内联函数形参中的lambda表达式可能会在另外一个调用栈中执行,例如:

1
2
3
4
5
6
inline fun f(crossinline body: () -> Unit) {
val f = object: Runnable {
override fun run() = body()
}
// ……
}

正常情况下内联的lambda允许非局部返回,返回的是内联函数调用处的函数,但是不在一个调用栈中,非局部返回就无法做到这样的返回,所以必须禁止,用crossinline来禁止非局部返回,但仍然保持内联的特性,把lambda表达式的代码展开铺平。

内联类

  • 内联函数,可以消除函数调用的开销。
  • 内联类,则是可以消除创建对象的开销。

用途:

  • 严格的类型别名
  • 任何你想得到的包装类(wrapper)

参考:Kotlin 1.3 前瞻之 Inline Class

Kotlin调Java可能返回为空的函数

Java 中的任何引用都可能是 null,这使得 Kotlin 对来自 Java 的对象要求严格空安全是不现实的。 Java 声明的类型在 Kotlin 中会被特别对待并称为平台类型。

当把一个平台值赋值给一个 Kotlin 变量时,可以选择我们期望的类型(可空或非空类型):

1
2
val nullable: String? = item // 允许,没有问题
val notNull: String = item // 允许,运行时可能失败

如果我们选择非空类型,编译器会在赋值时触发一个断言。这防止 Kotlin 的非空变量保存空值。当我们把平台值传递给期待非空值等的 Kotlin 函数时,也会触发断言。 总的来说,编译器尽力阻止空值通过程序向远传播(尽管鉴于泛型的原因,有时这不可能完全消除)。

如果我们选择非空类型,编译器会在赋值时触发一个断言。这防止 Kotlin 的非空变量保存空值。当我们把平台值传递给期待非空值等的 Kotlin 函数时,也会触发断言。 总的来说,编译器尽力阻止空值通过程序向远传播。

只有在Java层给方法加上@Nullable之后才会提示返回值可空。

空安全实际遇到的问题

一开始有一个Java的旧类,一直没有改动过,但是有需求要有不同的实现,所以需要把类的各个方法抽象为接口,接口类用Kotlin实现,接口的实现类还是原来的Java类,其中有个方法在Kotlin的接口中返回值是非空的String,java实现类中返回值是String,但是没有加@NotNull的注解,也就是可空的。

在实际调用这个方法的时候,当java实现类中返回null,就会报空指针异常。

Nullable注解应该是哪个库哪个包的注解?

具有可空性注解的Java类型并不表示为平台类型,而是表示为实际可空或非空的 Kotlin 类型。编译器支持多种可空性注解,包括:

  • JetBrains (org.jetbrains.annotations 包中的 @Nullable 和 @NotNull)

  • Android(com.android.annotations 和 android.support.annotations)

  • JSR-305(javax.annotation,详见下文)

  • FindBugs(edu.umd.cs.findbugs.annotations)

  • Eclipse(org.eclipse.jdt.annotation)

  • Lombok(lombok.NonNull)。

你可以在 Kotlin 编译器源代码中找到完整的列表。

参考资料