递归反转链表的一部分

反转单链表的迭代实现不是一个困难的事情,但是递归实现就有点难度了,如果再加一点难度,让你仅仅反转单链表中的一部分,你是否能够递归实现呢?
本文就来由浅入深,step by step 地解决这个问题。如果你还不会递归地反转单链表也没关系,本文会从递归反转整个单链表开始拓展,只要你明白单链表的结构,相信你能够有所收获。

1
2
3
4
5
6
// 单链表节点的结构
public class ListNode {
int val;
ListNode next;
ListNode(int x) { val = x; }
}

什么叫反转单链表的一部分呢,就是给你一个索引区间,让你把单链表中这部分元素反转,其他部分不变:

注意这里的索引是从 1 开始的。迭代的思路大概是:先用一个 for 循环找到第 m 个位置,然后再用一个 for 循环将 mn 之间的元素反转。但是我们的递归解法不用一个 for 循环,纯递归实现反转。
迭代实现思路看起来虽然简单,但是细节问题很多的,反而不容易写对。相反,递归实现就很简洁优美,下面就由浅入深,先从反转整个单链表说起。

一、递归反转整个链表

这个算法可能很多读者都听说过,这里详细介绍一下,先直接看实现代码:

1
2
3
4
5
6
7
ListNode reverse(ListNode head) {
if (head.next == null) return head;
ListNode last = reverse(head.next);
head.next.next = head;
head.next = null;
return last;
}

看起来是不是感觉不知所云,完全不能理解这样为什么能够反转链表?这就对了,这个算法常常拿来显示递归的巧妙和优美,我们下面来详细解释一下这段代码。
对于递归算法,最重要的就是明确递归函数的定义。具体来说,我们的 reverse 函数定义是这样的:
输入一个节点 head,将「以 head 为起点」的链表反转,并返回反转之后的头结点
明白了函数的定义,在来看这个问题。比如说我们想反转这个链表:

那么输入 reverse(head) 后,会在这里进行递归:

1
ListNode last = reverse(head.next);

不要跳进递归(你的脑袋能压几个栈呀?),而是要根据刚才的函数定义,来弄清楚这段代码会产生什么结果:

这个 reverse(head.next) 执行完成后,整个链表就成了这样:

并且根据函数定义,reverse 函数会返回反转之后的头结点,我们用变量 last 接收了。
现在再来看下面的代码:

1
head.next.next = head;


接下来:

1
2
head.next = null;
return last;


神不神奇,这样整个链表就反转过来了!递归代码就是这么简洁优雅,不过其中有两个地方需要注意:
1、递归函数要有 base case,也就是这句:

1
if (head.next == null) return head;

意思是如果链表只有一个节点的时候反转也是它自己,直接返回即可。
2、当链表递归反转之后,新的头结点是 last,而之前的 head 变成了最后一个节点,别忘了链表的末尾要指向 null:

1
head.next = null;

理解了这两点后,我们就可以进一步深入了,接下来的问题其实都是在这个算法上的扩展。

二、反转链表前 N 个节点

这次我们实现一个这样的函数:

1
2
// 将链表的前 n 个节点反转(n <= 链表长度)
ListNode reverseN(ListNode head, int n)

比如说对于下图链表,执行 reverseN(head, 3)

解决思路和反转整个链表差不多,只要稍加修改即可:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
ListNode successor = null; // 后驱节点
// 反转以 head 为起点的 n 个节点,返回新的头结点
ListNode reverseN(ListNode head, int n) {
if (n == 1) {
// 记录第 n + 1 个节点
successor = head.next;
return head;
}
// 以 head.next 为起点,需要反转前 n - 1 个节点
ListNode last = reverseN(head.next, n - 1);
head.next.next = head;
// 让反转之后的 head 节点和后面的节点连起来
head.next = successor;
return last;
}

具体的区别:
1、base case 变为 n == 1,反转一个元素,就是它本身,同时要记录后驱节点
2、刚才我们直接把 head.next 设置为 null,因为整个链表反转后原来的 head 变成了整个链表的最后一个节点。但现在 head 节点在递归反转之后不一定是最后一个节点了,所以要记录后驱 successor(第 n + 1 个节点),反转之后将 head 连接上。

OK,如果这个函数你也能看懂,就离实现「反转一部分链表」不远了。

三、反转链表的一部分

现在解决我们最开始提出的问题,给一个索引区间 [m,n](索引从 1 开始),仅仅反转区间中的链表元素。

1
ListNode reverseBetween(ListNode head, int m, int n)

首先,如果 m == 1,就相当于反转链表开头的 n 个元素嘛,也就是我们刚才实现的功能:

1
2
3
4
5
6
7
8
ListNode reverseBetween(ListNode head, int m, int n) {
// base case
if (m == 1) {
// 相当于反转前 n 个元素
return reverseN(head, n);
}
// ...
}

如果 m != 1 怎么办?如果我们把 head 的索引视为 1,那么我们是想从第 m 个元素开始反转对吧;如果把 head.next 的索引视为 1 呢?那么相对于 head.next,反转的区间应该是从第 m - 1 个元素开始的;那么对于 head.next.next 呢……
区别于迭代思想,这就是递归思想,所以我们可以完成代码:

1
2
3
4
5
6
7
8
9
ListNode reverseBetween(ListNode head, int m, int n) {
// base case
if (m == 1) {
return reverseN(head, n);
}
// 前进到反转的起点触发 base case
head.next = reverseBetween(head.next, m - 1, n - 1);
return head;
}

至此,我们的最终大 BOSS 就被解决了。

四、最后总结

递归的思想相对迭代思想,稍微有点难以理解,处理的技巧是:不要跳进递归,而是利用明确的定义来实现算法逻辑。
处理看起来比较困难的问题,可以尝试化整为零,把一些简单的解法进行修改,解决困难的问题。
值得一提的是,递归操作链表并不高效。和迭代解法相比,虽然时间复杂度都是 O(N),但是迭代解法的空间复杂度是 O(1),而递归解法需要堆栈,空间复杂度是 O(N)。所以递归操作链表可以作为对递归算法的练习或者拿去和小伙伴装逼,但是考虑效率的话还是使用迭代算法更好。

上一篇:设计Twitter
下一篇:队列实现栈|栈实现队列
目录

队列实现栈|栈实现队列

队列是一种先进先出的数据结构,栈是一种先进后出的数据结构,形象一点就是这样:

这两种数据结构底层其实都是数组或者链表实现的,只是 API 限定了它们的特性,那么今天就来看看如何使用「栈」的特性来实现一个「队列」,如何用「队列」实现一个「栈」。

一、用栈实现队列

首先,队列的 API 如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class MyQueue {

/** 添加元素到队尾 */
public void push(int x);

/** 删除队头的元素并返回 */
public int pop();

/** 返回队头元素 */
public int peek();

/** 判断队列是否为空 */
public boolean empty();
}

我们使用两个栈 s1, s2 就能实现一个队列的功能(这样放置栈可能更容易理解):

1
2
3
4
5
6
7
8
9
class MyQueue {
private Stack<Integer> s1, s2;

public MyQueue() {
s1 = new Stack<>();
s2 = new Stack<>();
}
// ...
}

当调用 push 让元素入队时,只要把元素压入 s1 即可,比如说 push 进 3 个元素分别是 1,2,3,那么底层结构就是这样:

1
2
3
4
/** 添加元素到队尾 */
public void push(int x) {
s1.push(x);
}

那么如果这时候使用 peek 查看队头的元素怎么办呢?按道理队头元素应该是 1,但是在 s1 中 1 被压在栈底,现在就要轮到 s2 起到一个中转的作用了:当 s2 为空时,可以把 s1 的所有元素取出再添加进 s2这时候 s2 中元素就是先进先出顺序了

1
2
3
4
5
6
7
8
/** 返回队头元素 */
public int peek() {
if (s2.isEmpty())
// 把 s1 元素压入 s2
while (!s1.isEmpty())
s2.push(s1.pop());
return s2.peek();
}

同理,对于 pop 操作,只要操作 s2 就可以了。

1
2
3
4
5
6
/** 删除队头的元素并返回 */
public int pop() {
// 先调用 peek 保证 s2 非空
peek();
return s2.pop();
}

最后,如何判断队列是否为空呢?如果两个栈都为空的话,就说明队列为空:

1
2
3
4
/** 判断队列是否为空 */
public boolean empty() {
return s1.isEmpty() && s2.isEmpty();
}

至此,就用栈结构实现了一个队列,核心思想是利用两个栈互相配合。
值得一提的是,这几个操作的时间复杂度是多少呢?有点意思的是 peek 操作,调用它时可能触发 while 循环,这样的话时间复杂度是 O(N),但是大部分情况下 while 循环不会被触发,时间复杂度是 O(1)。由于 pop 操作调用了 peek,它的时间复杂度和 peek 相同。
像这种情况,可以说它们的最坏时间复杂度是 O(N),因为包含 while 循环,可能需要从 s1s2 搬移元素。
但是它们的均摊时间复杂度是 O(1),这个要这么理解:对于一个元素,最多只可能被搬运一次,也就是说 peek 操作平均到每个元素的时间复杂度是 O(1)。

二、用队列实现栈

如果说双栈实现队列比较巧妙,那么用队列实现栈就比较简单粗暴了,只需要一个队列作为底层数据结构。首先看下栈的 API:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class MyStack {

/** 添加元素到栈顶 */
public void push(int x);

/** 删除栈顶的元素并返回 */
public int pop();

/** 返回栈顶元素 */
public int top();

/** 判断栈是否为空 */
public boolean empty();
}

先说 push API,直接将元素加入队列,同时记录队尾元素,因为队尾元素相当于栈顶元素,如果要 top 查看栈顶元素的话可以直接返回:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class MyStack {
Queue<Integer> q = new LinkedList<>();
int top_elem = 0;
/** 添加元素到栈顶 */
public void push(int x) {
// x 是队列的队尾,是栈的栈顶
q.offer(x);
top_elem = x;
}

/** 返回栈顶元素 */
public int top() {
return top_elem;
}
}

我们的底层数据结构是先进先出的队列,每次 pop 只能从队头取元素;但是栈是后进先出,也就是说 pop API 要从队尾取元素。

解决方法简单粗暴,把队列前面的都取出来再加入队尾,让之前的队尾元素排到队头,这样就可以取出了:

1
2
3
4
5
6
7
8
9
10
/** 删除栈顶的元素并返回 */
public int pop() {
int size = q.size();
while (size > 1) {
q.offer(q.poll());
size--;
}
// 之前的队尾元素已经到了队头
return q.poll();
}

这样实现还有一点小问题就是,原来的队尾元素被提到队头并删除了,但是 top_elem 变量没有更新,我们还需要一点小修改:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/** 删除栈顶的元素并返回 */
public int pop() {
int size = q.size();
// 留下队尾 2 个元素
while (size > 2) {
q.offer(q.poll());
size--;
}
// 记录新的队尾元素
top_elem = q.peek();
q.offer(q.poll());
// 删除之前的队尾元素
return q.poll();
}

最后,API empty 就很容易实现了,只要看底层的队列是否为空即可:

1
2
3
4
/** 判断栈是否为空 */
public boolean empty() {
return q.isEmpty();
}

很明显,用队列实现栈的话,pop 操作时间复杂度是 O(N),其他操作都是 O(1)​。​
个人认为,用队列实现栈是没啥亮点的问题,但是用双栈实现队列是值得学习的

从栈 s1 搬运元素到 s2 之后,元素在 s2 中就变成了队列的先进先出顺序,这个特性有点类似「负负得正」,确实不太容易想到。
希望本文对你有帮助。

上一篇:递归反转链表的一部分
下一篇:算法学习之路
目录

FloodFill算法详解及应用

啥是 FloodFill 算法呢,最直接的一个应用就是「颜色填充」,就是 Windows 绘画本中那个小油漆桶的标志,可以把一块被圈起来的区域全部染色。
floodfill
这种算法思想还在许多其他地方有应用。比如说扫雷游戏,有时候你点一个方格,会一下子展开一片区域,这个展开过程,就是 FloodFill 算法实现的。
扫雷
类似的,像消消乐这类游戏,相同方块积累到一定数量,就全部消除,也是 FloodFill 算法的功劳。
xiaoxiaole
通过以上的几个例子,你应该对 FloodFill 算法有个概念了,现在我们要抽象问题,提取共同点。

一、构建框架

以上几个例子,都可以抽象成一个二维矩阵(图片其实就是像素点矩阵),然后从某个点开始向四周扩展,直到无法再扩展为止。
矩阵,可以抽象为一幅「图」,这就是一个图的遍历问题,也就类似一个 N 叉树遍历的问题。几行代码就能解决,直接上框架吧:

1
2
3
4
5
6
7
// (x, y) 为坐标位置
void fill(int x, int y) {
fill(x - 1, y); // 上
fill(x + 1, y); // 下
fill(x, y - 1); // 左
fill(x, y + 1); // 右
}

这个框架可以解决所有在二维矩阵中遍历的问题,说得高端一点,这就叫深度优先搜索(Depth First Search,简称 DFS),说得简单一点,这就叫四叉树遍历框架。坐标 (x, y) 就是 root,四个方向就是 root 的四个子节点。
下面看一道 LeetCode 题目,其实就是让我们来实现一个「颜色填充」功能。
title
根据上篇文章,我们讲了「树」算法设计的一个总路线,今天就可以用到:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
int[][] floodFill(int[][] image,
int sr, int sc, int newColor) {
int origColor = image[sr][sc];
fill(image, sr, sc, origColor, newColor);
return image;
}
void fill(int[][] image, int x, int y,
int origColor, int newColor) {
// 出界:超出边界索引
if (!inArea(image, x, y)) return;
// 碰壁:遇到其他颜色,超出 origColor 区域
if (image[x][y] != origColor) return;
image[x][y] = newColor;

fill(image, x, y + 1, origColor, newColor);
fill(image, x, y - 1, origColor, newColor);
fill(image, x - 1, y, origColor, newColor);
fill(image, x + 1, y, origColor, newColor);
}
boolean inArea(int[][] image, int x, int y) {
return x >= 0 && x < image.length
&& y >= 0 && y < image[0].length;
}

只要你能够理解这段代码,一定要给你鼓掌,给你 99 分,因为你对「框架思维」的掌控已经炉火纯青,此算法已经 cover 了 99% 的情况,仅有一个细节问题没有解决,就是当 origColor 和 newColor 相同时,会陷入无限递归。

二、研究细节

为什么会陷入无限递归呢,很好理解,因为每个坐标都要搜索上下左右,那么对于一个坐标,一定会被上下左右的坐标搜索。被重复搜索时,必须保证递归函数能够能正确地退出,否则就会陷入死循环。
为什么 newColor 和 origColor 不同时可以正常退出呢?把算法流程画个图理解一下:
ppt1
可以看到,fill(1, 1) 被重复搜索了,我们用 fill(1, 1)* 表示这次重复搜索。fill(1, 1)* 执行时,(1, 1) 已经被换成了 newColor,所以 fill(1, 1)* 会在这个 if 语句被怼回去,正确退出了。

1
2
// 碰壁:遇到其他颜色,超出 origColor 区域
if (image[x][y] != origColor) return;

ppt2
但是,如果说 origColor 和 newColor 一样,这个 if 语句就无法让 fill(1, 1)* 正确退出,而是开启了下面的重复递归,形成了死循环。
ppt3

三、处理细节

如何避免上述问题的发生,最容易想到的就是用一个和 image 一样大小的二维 bool 数组记录走过的地方,一旦发现重复立即 return。

1
2
3
4
5
6
7
8
 // 出界:超出边界索引
if (!inArea(image, x, y)) return;
// 碰壁:遇到其他颜色,超出 origColor 区域
if (image[x][y] != origColor) return;
// 不走回头路
if (visited[x][y]) return;
visited[x][y] = true;
image[x][y] = newColor;

完全 OK,这也是处理「图」的一种常用手段。不过对于此题,不用开数组,我们有一种更好的方法,那就是回溯算法。
前文「回溯算法详解」讲过,这里不再赘述,直接套回溯算法框架:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
void fill(int[][] image, int x, int y,
int origColor, int newColor) {
// 出界:超出数组边界
if (!inArea(image, x, y)) return;
// 碰壁:遇到其他颜色,超出 origColor 区域
if (image[x][y] != origColor) return;
// 已探索过的 origColor 区域
if (image[x][y] == -1) return;

// choose:打标记,以免重复
image[x][y] = -1;
fill(image, x, y + 1, origColor, newColor);
fill(image, x, y - 1, origColor, newColor);
fill(image, x - 1, y, origColor, newColor);
fill(image, x + 1, y, origColor, newColor);
// unchoose:将标记替换为 newColor
image[x][y] = newColor;
}

这种解决方法是最常用的,相当于使用一个特殊值 -1 代替 visited 数组的作用,达到不走回头路的效果。为什么是 -1,因为题目中说了颜色取值在 0 - 65535 之间,所以 -1 足够特殊,能和颜色区分开。

四、拓展延伸:自动魔棒工具和扫雷

大部分图片编辑软件一定有「自动魔棒工具」这个功能:点击一个地方,帮你自动选中相近颜色的部分。如下图,我想选中老鹰,可以先用自动魔棒选中蓝天背景,然后反向选择,就选中了老鹰。我们来分析一下自动魔棒工具的原理。
抠图
显然,这个算法肯定是基于 FloodFill 算法的,但有两点不同:首先,背景色是蓝色,但不能保证都是相同的蓝色,毕竟是像素点,可能存在肉眼无法分辨的深浅差异,而我们希望能够忽略这种细微差异。第二,FloodFill 算法是「区域填充」,这里更像「边界填充」。
对于第一个问题,很好解决,可以设置一个阈值 threshold,在阈值范围内波动的颜色都视为 origColor:

1
2
if (Math.abs(image[x][y] - origColor) > threshold)
return;

对于第二个问题,我们首先明确问题:不要把区域内所有 origColor 的都染色,而是只给区域最外圈染色。然后,我们分析,如何才能仅给外围染色,即如何才能找到最外围坐标,最外围坐标有什么特点?
ppt4
可以发现,区域边界上的坐标,至少有一个方向不是 origColor,而区域内部的坐标,四面都是 origColor,这就是解决问题的关键。保持框架不变,使用 visited 数组记录已搜索坐标,主要代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
int fill(int[][] image, int x, int y,
int origColor, int newColor) {
// 出界:超出数组边界
if (!inArea(image, x, y)) return 0;
// 已探索过的 origColor 区域
if (visited[x][y]) return 1;
// 碰壁:遇到其他颜色,超出 origColor 区域
if (image[x][y] != origColor) return 0;
visited[x][y] = true;

int surround =
fill(image, x - 1, y, origColor, newColor)
+ fill(image, x + 1, y, origColor, newColor)
+ fill(image, x, y - 1, origColor, newColor)
+ fill(image, x, y + 1, origColor, newColor);

if (surround < 4)
image[x][y] = newColor;

return 1;
}

这样,区域内部的坐标探索四周后得到的 surround 是 4,而边界的坐标会遇到其他颜色,或超出边界索引,surround 会小于 4。如果你对这句话不理解,我们把逻辑框架抽象出来看:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
int fill(int[][] image, int x, int y,
int origColor, int newColor) {
// 出界:超出数组边界
if (!inArea(image, x, y)) return 0;
// 已探索过的 origColor 区域
if (visited[x][y]) return 1;
// 碰壁:遇到其他颜色,超出 origColor 区域
if (image[x][y] != origColor) return 0;
// 未探索且属于 origColor 区域
if (image[x][y] == origColor) {
// ...
return 1;
}
}

这 4 个 if 判断涵盖了 (x, y) 的所有可能情况,surround 的值由四个递归函数相加得到,而每个递归函数的返回值就这四种情况的一种。借助这个逻辑框架,你一定能理解上面那句话了。
这样就实现了仅对 origColor 区域边界坐标染色的目的,等同于完成了魔棒工具选定区域边界的功能。
这个算法有两个细节问题,一是必须借助 visited 来记录已探索的坐标,而无法使用回溯算法;二是开头几个 if 顺序不可打乱。读者可以思考一下原因。
同理,思考扫雷游戏,应用 FloodFill 算法展开空白区域的同时,也需要计算并显示边界上雷的个数,如何实现的?其实也是相同的思路,遇到雷就返回 true,这样 surround 变量存储的就是雷的个数。当然,扫雷的 FloodFill 算法不能只检查上下左右,还得加上四个斜向。

以上详细讲解了 FloodFill 算法的框架设计,二维矩阵中的搜索问题,都逃不出这个算法框架

上一篇:字符串乘法
下一篇:区间调度之区间合并问题
目录

Union-Find算法应用

上篇文章很多读者对于 Union-Find 算法的应用表示很感兴趣,这篇文章就拿几道 LeetCode 题目来讲讲这个算法的巧妙用法。
首先,复习一下,Union-Find 算法解决的是图的动态连通性问题,这个算法本身不难,能不能应用出来主要是看你抽象问题的能力,是否能够把原始问题抽象成一个有关图论的问题。
先复习一下上篇文章写的算法代码,回答读者提出的几个问题:

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
class UF {
// 记录连通分量个数
private int count;
// 存储若干棵树
private int[] parent;
// 记录树的“重量”
private int[] size;
public UF(int n) {
this.count = n;
parent = new int[n];
size = new int[n];
for (int i = 0; i < n; i++) {
parent[i] = i;
size[i] = 1;
}
}

/* 将 p 和 q 连通 */
public void union(int p, int q) {
int rootP = find(p);
int rootQ = find(q);
if (rootP == rootQ)
return;

// 小树接到大树下面,较平衡
if (size[rootP] > size[rootQ]) {
parent[rootQ] = rootP;
size[rootP] += size[rootQ];
} else {
parent[rootP] = rootQ;
size[rootQ] += size[rootP];
}
count--;
}
/* 判断 p 和 q 是否互相连通 */
public boolean connected(int p, int q) {
int rootP = find(p);
int rootQ = find(q);
// 处于同一棵树上的节点,相互连通
return rootP == rootQ;
}
/* 返回节点 x 的根节点 */
private int find(int x) {
while (parent[x] != x) {
// 进行路径压缩
parent[x] = parent[parent[x]];
x = parent[x];
}
return x;
}

public int count() {
return count;
}
}

算法的关键点有 3 个:
1、用 parent 数组记录每个节点的父节点,相当于指向父节点的指针,所以 parent 数组内实际存储着一个森林(若干棵多叉树)。
2、用 size 数组记录着每棵树的重量,目的是让 union 后树依然拥有平衡性,而不会退化成链表,影响操作效率。
3、在 find 函数中进行路径压缩,保证任意树的高度保持在常数,使得 unionconnected API 时间复杂度为 O(1)。
有的读者问,既然有了路径压缩,size 数组的重量平衡还需要吗?这个问题很有意思,因为路径压缩保证了树高为常数(不超过 3),那么树就算不平衡,高度也是常数,基本没什么影响。
我认为,论时间复杂度的话,确实,不需要重量平衡也是 O(1)。但是如果加上 size 数组辅助,效率还是略微高一些,比如下面这种情况:

如果带有重量平衡优化,一定会得到情况一,而不带重量优化,可能出现情况二。高度为 3 时才会触发路径压缩那个 while 循环,所以情况一根本不会触发路径压缩,而情况二会多执行很多次路径压缩,将第三层节点压缩到第二层。
也就是说,去掉重量平衡,虽然对于单个的 find 函数调用,时间复杂度依然是 O(1),但是对于 API 调用的整个过程,效率会有一定的下降。当然,好处就是减少了一些空间,不过对于 Big O 表示法来说,时空复杂度都没变。
下面言归正传,来看看这个算法有什么实际应用。

一、DFS 的替代方案

很多使用 DFS 深度优先算法解决的问题,也可以用 Union-Find 算法解决。
比如第 130 题,被围绕的区域:给你一个 M×N 的二维矩阵,其中包含字符 XO,让你找到矩阵中四面X 围住的 O,并且把它们替换成 X

1
void solve(char[][] board);

注意哦,必须是四面被围的 O 才能被换成 X,也就是说边角上的 O 一定不会被围,进一步,与边角上的 O 相连的 O 也不会被 X 围四面,也不会被替换。

PS:这让我想起小时候玩的棋类游戏「黑白棋」,只要你用两个棋子把对方的棋子夹在中间,对方的子就被替换成你的子。可见,占据四角的棋子是无敌的,与其相连的边棋子也是无敌的(无法被夹掉)。
解决这个问题的传统方法也不困难,先用 for 循环遍历棋盘的四边,用 DFS 算法把那些与边界相连的 O 换成一个特殊字符,比如 #;然后再遍历整个棋盘,把剩下的 O 换成 X,把 # 恢复成 O。这样就能完成题目的要求,时间复杂度 O(MN)。
这个问题也可以用 Union-Find 算法解决,虽然实现复杂一些,甚至效率也略低,但这是使用 Union-Find 算法的通用思想,值得一学。
你可以把那些不需要被替换的 O 看成一个拥有独门绝技的门派,它们有一个共同祖师爷叫 dummy,这些 Odummy 互相连通,而那些需要被替换的 Odummy 不连通

这就是 Union-Find 的核心思路,明白这个图,就很容易看懂代码了。
首先要解决的是,根据我们的实现,Union-Find 底层用的是一维数组,构造函数需要传入这个数组的大小,而题目给的是一个二维棋盘。
这个很简单,二维坐标 (x,y) 可以转换成 x * n + y 这个数(m 是棋盘的行数,n 是棋盘的列数)。敲黑板,这是将二维坐标映射到一维的常用技巧
其次,我们之前描述的「祖师爷」是虚构的,需要给他老人家留个位置。索引 [0.. m*n-1] 都是棋盘内坐标的一维映射,那就让这个虚拟的 dummy 节点占据索引 m * n 好了。

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
void solve(char[][] board) {
if (board.length == 0) return;
int m = board.length;
int n = board[0].length;
// 给 dummy 留一个额外位置
UF uf = new UF(m * n + 1);
int dummy = m * n;
// 将首列和末列的 O 与 dummy 连通
for (int i = 0; i < m; i++) {
if (board[i][0] == 'O')
uf.union(i * n, dummy);
if (board[i][n - 1] == 'O')
uf.union(i * n + n - 1, dummy);
}
// 将首行和末行的 O 与 dummy 连通
for (int j = 0; j < n; j++) {
if (board[0][j] == 'O')
uf.union(j, dummy);
if (board[m - 1][j] == 'O')
uf.union(n * (m - 1) + j, dummy);
}
// 方向数组 d 是上下左右搜索的常用手法
int[][] d = new int[][]{{1,0}, {0,1}, {0,-1}, {-1,0}};
for (int i = 1; i < m - 1; i++)
for (int j = 1; j < n - 1; j++)
if (board[i][j] == 'O')
// 将此 O 与上下左右的 O 连通
for (int k = 0; k < 4; k++) {
int x = i + d[k][0];
int y = j + d[k][1];
if (board[x][y] == 'O')
uf.union(x * n + y, i * n + j);
}
// 所有不和 dummy 连通的 O,都要被替换
for (int i = 1; i < m - 1; i++)
for (int j = 1; j < n - 1; j++)
if (!uf.connected(dummy, i * n + j))
board[i][j] = 'X';
}

这段代码很长,其实就是刚才的思路实现,只有和边界 O 相连的 O 才具有和 dummy 的连通性,他们不会被替换。
说实话,Union-Find 算法解决这个简单的问题有点杀鸡用牛刀,它可以解决更复杂,更具有技巧性的问题,主要思路是适时增加虚拟节点,想办法让元素「分门别类」,建立动态连通关系

二、判定合法等式

这个问题用 Union-Find 算法就显得十分优美了。题目是这样:
给你一个数组 equations,装着若干字符串表示的算式。每个算式 equations[i] 长度都是 4,而且只有这两种情况:a==b 或者 a!=b,其中 a,b 可以是任意小写字母。你写一个算法,如果 equations 中所有算式都不会互相冲突,返回 true,否则返回 false。
比如说,输入 ["a==b","b!=c","c==a"],算法返回 false,因为这三个算式不可能同时正确。
再比如,输入 ["c==c","b==d","x!=z"],算法返回 true,因为这三个算式并不会造成逻辑冲突。
我们前文说过,动态连通性其实就是一种等价关系,具有「自反性」「传递性」和「对称性」,其实 == 关系也是一种等价关系,具有这些性质。所以这个问题用 Union-Find 算法就很自然。
核心思想是,equations 中的算式根据 ==!= 分成两部分,先处理 == 算式,使得他们通过相等关系各自勾结成门派;然后处理 != 算式,检查不等关系是否破坏了相等关系的连通性

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
boolean equationsPossible(String[] equations) {
// 26 个英文字母
UF uf = new UF(26);
// 先让相等的字母形成连通分量
for (String eq : equations) {
if (eq.charAt(1) == '=') {
char x = eq.charAt(0);
char y = eq.charAt(3);
uf.union(x - 'a', y - 'a');
}
}
// 检查不等关系是否打破相等关系的连通性
for (String eq : equations) {
if (eq.charAt(1) == '!') {
char x = eq.charAt(0);
char y = eq.charAt(3);
// 如果相等关系成立,就是逻辑冲突
if (uf.connected(x - 'a', y - 'a'))
return false;
}
}
return true;
}

至此,这道判断算式合法性的问题就解决了,借助 Union-Find 算法,是不是很简单呢?

三、简单总结

使用 Union-Find 算法,主要是如何把原问题转化成图的动态连通性问题。对于算式合法性问题,可以直接利用等价关系,对于棋盘包围问题,则是利用一个虚拟节点,营造出动态连通特性。
另外,将二维数组映射到一维数组,利用方向数组 d 来简化代码量,都是在写算法时常用的一些小技巧,如果没见过可以注意一下。
很多更复杂的 DFS 算法问题,都可以利用 Union-Find 算法更漂亮的解决。LeetCode 上 Union-Find 相关的问题也就二十多道,有兴趣的读者可以去做一做。

上一篇:Union-Find算法详解
下一篇:一行代码就能解决的算法题
目录

Union-Find算法详解

今天讲讲 Union-Find 算法,也就是常说的并查集算法,主要是解决图论中「动态连通性」问题的。名词很高端,其实特别好理解,等会解释,另外这个算法的应用都非常有趣。
说起这个 Union-Find,应该算是我的「启蒙算法」了,因为《算法4》的开头就介绍了这款算法,可是把我秀翻了,感觉好精妙啊!后来刷了 LeetCode,并查集相关的算法题目都非常有意思,而且《算法4》给的解法竟然还可以进一步优化,只要加一个微小的修改就可以把时间复杂度降到 O(1)。
废话不多说,直接上干货,先解释一下什么叫动态连通性吧。

一、问题介绍

简单说,动态连通性其实可以抽象成给一幅图连线。比如下面这幅图,总共有 10 个节点,他们互不相连,分别用 0~9 标记:

现在我们的 Union-Find 算法主要需要实现这两个 API:

1
2
3
4
5
6
7
8
class UF {
/* 将 p 和 q 连接 */
public void union(int p, int q);
/* 判断 p 和 q 是否连通 */
public boolean connected(int p, int q);
/* 返回图中有多少个连通分量 */
public int count();
}

这里所说的「连通」是一种等价关系,也就是说具有如下三个性质:
1、自反性:节点pp是连通的。
2、对称性:如果节点pq连通,那么qp也连通。
3、传递性:如果节点pq连通,qr连通,那么pr也连通。
比如说之前那幅图,0~9 任意两个不同的点都不连通,调用connected都会返回 false,连通分量为 10 个。
如果现在调用union(0, 1),那么 0 和 1 被连通,连通分量降为 9 个。
再调用union(1, 2),这时 0,1,2 都被连通,调用connected(0, 2)也会返回 true,连通分量变为 8 个。

判断这种「等价关系」非常实用,比如说编译器判断同一个变量的不同引用,比如社交网络中的朋友圈计算等等。
这样,你应该大概明白什么是动态连通性了,Union-Find 算法的关键就在于unionconnected函数的效率。那么用什么模型来表示这幅图的连通状态呢?用什么数据结构来实现代码呢?

二、基本思路

注意我刚才把「模型」和具体的「数据结构」分开说,这么做是有原因的。因为我们使用森林(若干棵树)来表示图的动态连通性,用数组来具体实现这个森林。
怎么用森林来表示连通性呢?我们设定树的每个节点有一个指针指向其父节点,如果是根节点的话,这个指针指向自己。比如说刚才那幅 10 个节点的图,一开始的时候没有相互连通,就是这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class UF {
// 记录连通分量
private int count;
// 节点 x 的节点是 parent[x]
private int[] parent;
/* 构造函数,n 为图的节点总数 */
public UF(int n) {
// 一开始互不连通
this.count = n;
// 父节点指针初始指向自己
parent = new int[n];
for (int i = 0; i < n; i++)
parent[i] = i;
}
/* 其他函数 */
}

如果某两个节点被连通,则让其中的(任意)一个节点的根节点接到另一个节点的根节点上

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public void union(int p, int q) {
int rootP = find(p);
int rootQ = find(q);
if (rootP == rootQ)
return;
// 将两棵树合并为一棵
parent[rootP] = rootQ;
// parent[rootQ] = rootP 也一样
count--; // 两个分量合二为一
}
/* 返回某个节点 x 的根节点 */
private int find(int x) {
// 根节点的 parent[x] == x
while (parent[x] != x)
x = parent[x];
return x;
}
/* 返回当前的连通分量个数 */
public int count() {
return count;
}

这样,如果节点pq连通的话,它们一定拥有相同的根节点

1
2
3
4
5
public boolean connected(int p, int q) {
int rootP = find(p);
int rootQ = find(q);
return rootP == rootQ;
}

至此,Union-Find 算法就基本完成了。是不是很神奇?竟然可以这样使用数组来模拟出一个森林,如此巧妙的解决这个比较复杂的问题!
那么这个算法的复杂度是多少呢?我们发现,主要 APIconnectedunion中的复杂度都是find函数造成的,所以说它们的复杂度和find一样。
find主要功能就是从某个节点向上遍历到树根,其时间复杂度就是树的高度。我们可能习惯性地认为树的高度就是logN,但这并不一定。logN的高度只存在于平衡二叉树,对于一般的树可能出现极端不平衡的情况,使得「树」几乎退化成「链表」,树的高度最坏情况下可能变成N

所以说上面这种解法,find,union,connected的时间复杂度都是 O(N)。这个复杂度很不理想的,你想图论解决的都是诸如社交网络这样数据规模巨大的问题,对于unionconnected的调用非常频繁,每次调用需要线性时间完全不可忍受。
问题的关键在于,如何想办法避免树的不平衡呢?只需要略施小计即可。

三、平衡性优化

我们要知道哪种情况下可能出现不平衡现象,关键在于union过程:

1
2
3
4
5
6
7
8
9
public void union(int p, int q) {
int rootP = find(p);
int rootQ = find(q);
if (rootP == rootQ)
return;
// 将两棵树合并为一棵
parent[rootP] = rootQ;
// parent[rootQ] = rootP 也可以
count--;

我们一开始就是简单粗暴的把p所在的树接到q所在的树的根节点下面,那么这里就可能出现「头重脚轻」的不平衡状况,比如下面这种局面:

长此以往,树可能生长得很不平衡。我们其实是希望,小一些的树接到大一些的树下面,这样就能避免头重脚轻,更平衡一些。解决方法是额外使用一个size数组,记录每棵树包含的节点数,我们不妨称为「重量」:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class UF {
private int count;
private int[] parent;
// 新增一个数组记录树的“重量”
private int[] size;
public UF(int n) {
this.count = n;
parent = new int[n];
// 最初每棵树只有一个节点
// 重量应该初始化 1
size = new int[n];
for (int i = 0; i < n; i++) {
parent[i] = i;
size[i] = 1;
}
}
/* 其他函数 */
}

比如说size[3] = 5表示,以节点3为根的那棵树,总共有5个节点。这样我们可以修改一下union方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public void union(int p, int q) {
int rootP = find(p);
int rootQ = find(q);
if (rootP == rootQ)
return;

// 小树接到大树下面,较平衡
if (size[rootP] > size[rootQ]) {
parent[rootQ] = rootP;
size[rootP] += size[rootQ];
} else {
parent[rootP] = rootQ;
size[rootQ] += size[rootP];
}
count--;
}

这样,通过比较树的重量,就可以保证树的生长相对平衡,树的高度大致在logN这个数量级,极大提升执行效率。
此时,find,union,connected的时间复杂度都下降为 O(logN),即便数据规模上亿,所需时间也非常少。

四、路径压缩

这步优化特别简单,所以非常巧妙。我们能不能进一步压缩每棵树的高度,使树高始终保持为常数?

这样find就能以 O(1) 的时间找到某一节点的根节点,相应的,connectedunion复杂度都下降为 O(1)。
要做到这一点,非常简单,只需要在find中加一行代码:

1
2
3
4
5
6
7
8
private int find(int x) {
while (parent[x] != x) {
// 进行路径压缩
parent[x] = parent[parent[x]];
x = parent[x];
}
return x;
}

这个操作有点匪夷所思,看个 GIF 就明白它的作用了(为清晰起见,这棵树比较极端):

可见,调用find函数每次向树根遍历的同时,顺手将树高缩短了,最终所有树高都不会超过 3(union的时候树高可能达到 3)。
PS:读者可能会问,这个 GIF 图的find过程完成之后,树高恰好等于 3 了,但是如果更高的树,压缩后高度依然会大于 3 呀?不能这么想。这个 GIF 的情景是我编出来方便大家理解路径压缩的,但是实际中,每次find都会进行路径压缩,所以树本来就不可能增长到这么高,你的这种担心应该是多余的。

五、最后总结

我们先来看一下完整代码:

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
class UF {
// 连通分量个数
private int count;
// 存储一棵树
private int[] parent;
// 记录树的“重量”
private int[] size;
public UF(int n) {
this.count = n;
parent = new int[n];
size = new int[n];
for (int i = 0; i < n; i++) {
parent[i] = i;
size[i] = 1;
}
}

public void union(int p, int q) {
int rootP = find(p);
int rootQ = find(q);
if (rootP == rootQ)
return;

// 小树接到大树下面,较平衡
if (size[rootP] > size[rootQ]) {
parent[rootQ] = rootP;
size[rootP] += size[rootQ];
} else {
parent[rootP] = rootQ;
size[rootQ] += size[rootP];
}
count--;
}
public boolean connected(int p, int q) {
int rootP = find(p);
int rootQ = find(q);
return rootP == rootQ;
}
private int find(int x) {
while (parent[x] != x) {
// 进行路径压缩
parent[x] = parent[parent[x]];
x = parent[x];
}
return x;
}
public int count() {
return count;
}
}

Union-Find 算法的复杂度可以这样分析:构造函数初始化数据结构需要 O(N) 的时间和空间复杂度;连通两个节点union、判断两个节点的连通性connected、计算连通分量count所需的时间复杂度均为 O(1)。

上一篇:如何调度考生的座位
下一篇:Union-Find算法应用
目录

twoSum问题的核心思想

Two Sum 系列问题在 LeetCode 上有好几道,这篇文章就挑出有代表性的几道,介绍一下这种问题怎么解决。

TwoSum I

这个问题的最基本形式是这样:给你一个数组和一个整数 target,可以保证数组中存在两个数的和为 target,请你返回这两个数的索引。
比如输入 nums = [3,1,3,6], target = 6,算法应该返回数组 [0,2],因为 3 + 3 = 6。
这个问题如何解决呢?首先最简单粗暴的办法当然是穷举了:

1
2
3
4
5
6
7
8
int[] twoSum(int[] nums, int target) {
for (int i = 0; i < nums.length; i++)
for (int j = i + 1; j < nums.length; j++)
if (nums[j] == target - nums[i])
return new int[] { i, j };
// 不存在这么两个数
return new int[] {-1, -1};
}

这个解法非常直接,时间复杂度 O(N^2),空间复杂度 O(1)。
可以通过一个哈希表减少时间复杂度:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
int[] twoSum(int[] nums, int target) {
int n = nums.length;
index<Integer, Integer> index = new HashMap<>();
// 构造一个哈希表:元素映射到相应的索引
for (int i = 0; i < n; i++)
index.put(nums[i], i);

for (int i = 0; i < n; i++) {
int other = target - nums[i];
// 如果 other 存在且不是 nums[i] 本身
if (index.containsKey(other) && index.get(other) != i)
return new int[] {i, index.get(other)};
}

return new int[] {-1, -1};
}

这样,由于哈希表的查询时间为 O(1),算法的时间复杂度降低到 O(N),但是需要 O(N) 的空间复杂度来存储哈希表。不过综合来看,是要比暴力解法高效的。
我觉得 Two Sum 系列问题就是想教我们如何使用哈希表处理问题。我们接着往后看。

TwoSum II

这里我们稍微修改一下上面的问题。我们设计一个类,拥有两个 API:

1
2
3
4
5
6
class TwoSum {
// 向数据结构中添加一个数 number
public void add(int number);
// 寻找当前数据结构中是否存在两个数的和为 value
public boolean find(int value);
}

如何实现这两个 API 呢,我们可以仿照上一道题目,使用一个哈希表辅助 find 方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class TwoSum {
Map<Integer, Integer> freq = new HashMap<>();
public void add(int number) {
// 记录 number 出现的次数
freq.put(number, freq.getOrDefault(number, 0) + 1);
}

public boolean find(int value) {
for (Integer key : freq.keySet()) {
int other = value - key;
// 情况一
if (other == key && freq.get(key) > 1)
return true;
// 情况二
if (other != key && freq.containsKey(other))
return true;
}
return false;
}
}

进行 find 的时候有两种情况,举个例子:
情况一:add[3,3,2,5] 之后,执行 find(6),由于 3 出现了两次,3 + 3 = 6,所以返回 true。
情况二:add[3,3,2,5] 之后,执行 find(7),那么 key 为 2,other 为 5 时算法可以返回 true。
除了上述两种情况外,find 只能返回 false 了。
对于这个解法的时间复杂度呢,add 方法是 O(1),find 方法是 O(N),空间复杂度为 O(N),和上一道题目比较类似。
但是对于 API 的设计,是需要考虑现实情况的。比如说,我们设计的这个类,使用 find 方法非常频繁,那么每次都要 O(N) 的时间,岂不是很浪费费时间吗?对于这种情况,我们是否可以做些优化呢?
是的,对于频繁使用 find 方法的场景,我们可以进行优化。我们可以参考上一道题目的暴力解法,借助哈希集合来针对性优化 find 方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class TwoSum {
Set<Integer> sum = new HashSet<>();
List<Integer> nums = new ArrayList<>();
public void add(int number) {
// 记录所有可能组成的和
for (int n : nums)
sum.add(n + number);
nums.add(number);
}

public boolean find(int value) {
return sum.contains(value);
}
}

这样 sum 中就储存了所有加入数字可能组成的和,每次 find 只要花费 O(1) 的时间在集合中判断一下是否存在就行了,显然非常适合频繁使用 find 的场景。

三、总结

对于 TwoSum 问题,一个难点就是给的数组无序。对于一个无序的数组,我们似乎什么技巧也没有,只能暴力穷举所有可能。
一般情况下,我们会首先把数组排序再考虑双指针技巧。TwoSum 启发我们,HashMap 或者 HashSet 也可以帮助我们处理无序数组相关的简单问题。
另外,设计的核心在于权衡,利用不同的数据结构,可以得到一些针对性的加强。
最后,如果 TwoSum I 中给的数组是有序的,应该如何编写算法呢?答案很简单,前文「双指针技巧汇总」写过:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
int[] twoSum(int[] nums, int target) {
int left = 0, right = nums.length - 1;
while (left < right) {
int sum = nums[left] + nums[right];
if (sum == target) {
return new int[]{left, right};
} else if (sum < target) {
left++; // 让 sum 大一点
} else if (sum > target) {
right--; // 让 sum 小一点
}
}
// 不存在这样两个数
return new int[]{-1, -1};
}

上一篇:滑动窗口技巧
下一篇:常用的位操作
目录

为什么我推荐《算法4》

咱们的公众号有很多硬核的算法文章,今天就聊点轻松的,就具体聊聊我非常“鼓吹”的《算法4》。这本书我在之前的文章多次推荐过,但是没有具体的介绍,今天就来正式介绍一下。。
我的推荐不会直接甩一大堆书目,而是会联系实际生活,讲一些书中有趣有用的知识,无论你最后会不会去看这本书,本文都会给你带来一些收获。
首先这本书是适合初学者的。总是有很多读者问,我只会 C 语言,能不能看《算法4》?学算法最好用什么语言?诸如此类的问题。
经常看咱们公众号的读者应该体会到了,算法其实是一种思维模式,和你用什么语言没啥关系。我们的文章也不会固定用某一种语言,而是什么语言写出来容易理解就用什么语言。再退一步说,到底适不适合你,网上找个 PDF 亲自看一下不就知道了?
《算法4》看起来挺厚的,但是前面几十页是教你 Java 的;每章后面还有习题,占了不少页数;每章还有一些数学证明,这些都可以忽略。这样算下来,剩下的就是基础知识和疑难解答之类的内容,含金量很高,把这些基础知识动手实践一遍,真的就可以达到不错的水平了。
我觉得这本书之所以能有这么高的评分,一个是因为讲解详细,还有大量配图,另一个原因就是书中把一些算法和现实生活中的使用场景联系起来,你不仅知道某个算法怎么实现,也知道它大概能运用到什么场景,下面我就来介绍两个图算法的简单应用。

一、二分图的应用

我想举的第一个例子是二分图。简单来说,二分图就是一幅拥有特殊性质的图:能够用两种颜色为所有顶点着色,使得任何一条边的两个顶点颜色不同。

明白了二分图是什么,能解决什么实际问题呢?算法方面,常见的操作是如何判定一幅图是不是二分图。比如说下面这道 LeetCode 题目:

你想想,如果我们把每个人视为一个顶点,边代表讨厌;相互讨厌的两个人之间连接一条边,就可以形成一幅图。那么根据刚才二分图的定义,如果这幅图是一幅二分图,就说明这些人可以被分为两组,否则的话就不行。
这是判定二分图算法的一个应用,其实二分图在数据结构方面也有一些不错的特性
比如说我们需要一种数据结构来储存电影和演员之间的关系:某一部电影肯定是由多位演员出演的,且某一位演员可能会出演多部电影。你使用什么数据结构来存储这种关系呢?
既然是存储映射关系,最简单的不就是使用哈希表嘛,我们可以使用一个 HashMap<String, List<String>> 来存储电影到演员列表的映射,如果给一部电影的名字,就能快速得到出演该电影的演员。
但是如果给出一个演员的名字,我们想快速得到该演员演出的所有电影,怎么办呢?这就需要「反向索引」,对之前的哈希表进行一些操作,新建另一个哈希表,把演员作为键,把电影列表作为值。
对于上面这个例子,可以使用二分图来取代哈希表。电影和演员是具有二分图性质的:如果把电影和演员视为图中的顶点,出演关系作为边,那么与电影顶点相连的一定是演员,与演员相邻的一定是电影,不存在演员和演员相连,电影和电影相连的情况。
回顾二分图的定义,如果对演员和电影顶点着色,肯定就是一幅二分图:

如果这幅图构建完成,就不需要反向索引,对于演员顶点,其直接连接的顶点就是他出演的电影,对于电影顶点,其直接连接的顶点就是出演演员。
当然,对于这个问题,书中还提到了一些其他有趣的玩法,比如说社交网络中「间隔度数」的计算(六度空间理论应该听说过)等等,其实就是一个 BFS 广度优先搜索寻找最短路径的问题,具体代码实现这里就不展开了。

二、套汇的算法

如果我们说货币 A 到货币 B 的汇率是 10,意思就是 1 单位的货币 A 可以换 10 单位货币 B。如果我们把每种货币视为一幅图的顶点,货币之间的汇率视为加权有向边,那么整个汇率市场就是一幅「完全加权有向图」。
一旦把现实生活中的情景抽象成图,就有可能运用算法解决一些问题。比如说图中可能存在下面的情况:

图中的加权有向边代表汇率,我们可以发现如果把 100 单位的货币 A 换成 B,再换成 C,最后换回 A,就可以得到 100×0.9×0.8×1.4 = 100.8 单位的 A!如果交易的金额大一些的话,赚的钱是很可观的,这种空手套白狼的操作就是套汇。
现实中交易会有种种限制,而且市场瞬息万变,但是套汇的利润还是很高的,关键就在于如何快速找到这种套汇机会呢?
借助图的抽象,我们发现套汇机会其实就是一个环,且这个环上的权重之积大于 1,只要在顺着这个环交易一圈就能空手套白狼。
图论中有一个经典算法叫做 Bellman-Ford 算法,可以用于寻找负权重环。对于我们说的套汇问题,可以先把所有边的权重 w 替换成 -ln(w),这样「寻找权重乘积大于 1 的环」就转化成了「寻找权重和小于 0 的环」,就可以使用 Bellman-Ford 算法在 O(EV) 的时间内寻找负权重环,也就是寻找套汇机会。
《算法4》就介绍到这里,关于上面两个例子的具体内容,可以自己去看书,公众号后台回复关键词「算法4」就有 PDF。

三、最后说几句

首先,前文说对于数学证明、章后习题可以忽略,可能有人要抬杠了:难道习题和数学证明不重要吗?
那我想说,就是不重要,起码对大多数人来说不重要。我觉得吧,学习就要带着目的性去学,大部分人学算法不就是巩固计算机知识,对付面试题目吗?如果是这个目的,那就学些基本的数据结构和经典算法,明白它们的时间复杂度,然后去刷题就好了,何必和习题、证明过不去?
这也是我从来不推荐《算法导论》这本书的原因。如果有人给你推荐这本书,只可能有两个原因,要么他是真大佬,要么他在装大佬。《算法导论》中充斥大量数学证明,而且很多数据结构是很少用到的,顶多当个字典用。你说你学了那些有啥用呢,饶过自己呗。
另外,读书在精不在多。你花时间《算法4》过个大半(最后小半部分有点困难),同时刷点题,看看咱们的公众号文章,算法这块真就够了,别对细节问题太较真。
致力于把算法讲清楚!欢迎关注我的微信公众号 labuladong,查看更多通俗易懂的文章,公众号后台回复关键词「算法4」可以获得 PDF 下载:

上一篇:学习算法和刷题的框架思维
下一篇:动态规划解题框架
目录

信封嵌套问题

很多算法问题都需要排序技巧,其难点不在于排序本身,而是需要巧妙地排序进行预处理,将算法问题进行转换,为之后的操作打下基础。
信封嵌套问题就需要先按特定的规则排序,之后就转换为一个 最长递增子序列问题,可以用前文 二分查找详解 的技巧来解决了。

一、题目概述

信封嵌套问题是个很有意思且经常出现在生活中的问题,先看下题目:
title
这道题目其实是最长递增子序列(Longes Increasing Subsequence,简写为 LIS)的一个变种,因为很显然,每次合法的嵌套是大的套小的,相当于找一个最长递增的子序列,其长度就是最多能嵌套的信封个数。
但是难点在于,标准的 LIS 算法只能在数组中寻找最长子序列,而我们的信封是由 (w, h) 这样的二维数对形式表示的,如何把 LIS 算法运用过来呢?
0
读者也许会想,通过 w × h 计算面积,然后对面积进行标准的 LIS 算法。但是稍加思考就会发现这样不行,比如 1 × 10 大于 3 × 3,但是显然这样的两个信封是无法互相嵌套的。

二、解法

这道题的解法是比较巧妙的:
先对宽度 w 进行升序排序,如果遇到 w 相同的情况,则按照高度 h 降序排序。之后把所有的 h 作为一个数组,在这个数组上计算 LIS 的长度就是答案。
画个图理解一下,先对这些数对进行排序:
1
然后在 h 上寻找最长递增子序列:
2
这个子序列就是最优的嵌套方案。
这个解法的关键在于,对于宽度 w 相同的数对,要对其高度 h 进行降序排序。因为两个宽度相同的信封不能相互包含的,逆序排序保证在 w 相同的数对中最多只选取一个。
下面看代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// envelopes = [[w, h], [w, h]...]
public int maxEnvelopes(int[][] envelopes) {
int n = envelopes.length;
// 按宽度升序排列,如果宽度一样,则按高度降序排列
Arrays.sort(envelopes, new Comparator<int[]>()
{
public int compare(int[] a, int[] b) {
return a[0] == b[0] ?
b[1] - a[1] : a[0] - b[0];
}
});
// 对高度数组寻找 LIS
int[] height = new int[n];
for (int i = 0; i < n; i++)
height[i] = envelopes[i][1];
return lengthOfLIS(height);
}

关于最长递增子序列的寻找方法,在前文中详细介绍了动态规划解法,并用扑克牌游戏解释了二分查找解法,本文就不展开了,直接套用算法模板:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
/* 返回 nums 中 LIS 的长度 */
public int lengthOfLIS(int[] nums) {
int piles = 0, n = nums.length;
int[] top = new int[n];
for (int i = 0; i < n; i++) {
// 要处理的扑克牌
int poker = nums[i];
int left = 0, right = piles;
// 二分查找插入位置
while (left < right) {
int mid = (left + right) / 2;
if (top[mid] >= poker)
right = mid;
else
left = mid + 1;
}
if (left == piles) piles++;
// 把这张牌放到牌堆顶
top[left] = poker;
}
// 牌堆数就是 LIS 长度
return piles;
}

为了清晰,我将代码分为了两个函数, 你也可以合并,这样可以节省下 height 数组的空间。
此算法的时间复杂度为 $O(NlogN)$,因为排序和计算 LIS 各需要 $O(NlogN)$ 的时间。
空间复杂度为 $O(N)$,因为计算 LIS 的函数中需要一个 top 数组。

三、总结

这个问题是个 Hard 级别的题目,难就难在排序,正确地排序后此问题就被转化成了一个标准的 LIS 问题,容易解决一些。
其实这种问题还可以拓展到三维,比如说现在不是让你嵌套信封,而是嵌套箱子,每个箱子有长宽高三个维度,请你算算最多能嵌套几个箱子?
我们可能会这样想,先把前两个维度(长和宽)按信封嵌套的思路求一个嵌套序列,最后在这个序列的第三个维度(高度)找一下 LIS,应该能算出答案。
实际上,这个思路是错误的。这类问题叫做「偏序问题」,上升到三维会使难度巨幅提升,需要借助一种高级数据结构「树状数组」,有兴趣的读者可以自行搜索。
有很多算法问题都需要排序后进行处理,阿东正在进行整理总结。希望本文对你有帮助。

上一篇:区间调度之区间交集问题
下一篇:几个反直觉的概率问题
目录

几个反直觉的概率问题

上篇文章 洗牌算法详解 讲到了验证概率算法的蒙特卡罗方法,今天聊点轻松的内容:几个和概率相关的有趣问题。
计算概率有下面两个最简单的原则:
原则一、计算概率一定要有一个参照系,称作「样本空间」,即随机事件可能出现的所有结果。事件 A 发生的概率 = A 包含的样本点 / 样本空间的样本总数。
原则二、计算概率一定要明白,概率是一个连续的整体,不可以把连续的概率分割开,也就是所谓的条件概率。
上述两个原则高中就学过,但是我们还是很容易犯错,而且犯错的流程也有异曲同工之妙:
先是忽略了原则二,错误地计算了样本空间,然后通过原则一算出了错误的答案。
下面介绍几个简单却具有迷惑性的问题,分别是男孩女孩问题、生日悖论、三门问题。当然,三门问题可能是大家最耳熟的,所以就多说一些有趣的思考。

一、男孩女孩问题

假设有一个家庭,有两个孩子,现在告诉你其中有一个男孩,请问另一个也是男孩的概率是多少?
很多人,包括我在内,不假思索地回答:1/2 啊,因为另一个孩子要么是男孩,要么是女孩,而且概率相等呀。但是实际上,答案是 1/3。
上述思想为什么错误呢?因为没有正确计算样本空间,导致原则一计算错误。有两个孩子,那么样本空间为 4,即哥哥妹妹,哥哥弟弟,姐姐妹妹,姐姐弟弟这四种情况。已知有一个男孩,那么排除姐姐妹妹这种情况,所以样本空间变成 3。另一个孩子也是男孩只有哥哥弟弟这 1 种情况,所以概率为 1/3。
为什么计算样本空间会出错呢?因为我们忽略了条件概率,即混淆了下面两个问题:
这个家庭只有一个孩子,这个孩子是男孩的概率是多少?
这个家庭有两个孩子,其中一个是男孩,另一个孩子是男孩的概率是多少?
根据原则二,概率问题是连续的,不可以把上述两个问题混淆。第二个问题需要用条件概率,即求一个孩子是男孩的条件下,另一个也是男孩的概率。运用条件概率的公式也很好算,就不多说了。
通过这个问题,读者应该理解两个概率计算原则的关系了,最具有迷惑性的就是条件概率的忽视。为了不要被迷惑,最简单的办法就是把所有可能结果穷举出来。
最后,对于此问题我见过一个很奇葩的质疑:如果这两个孩子是双胞胎,不存在年龄上的差异怎么办?
我竟然觉得有那么一丝道理!但其实,我们只是通过年龄差异来表示两个孩子的独立性,也就是说即便两个孩子同性,也有两种可能。所以不要用双胞胎抬杠了。

二、生日悖论

生日悖论是由这样一个问题引出的:一个屋子里需要有多少人,才能使得存在至少两个人生日是同一天的概率达到 50%?
答案是 23 个人,也就是说房子里如果有 23 个人,那么就有 50% 的概率会存在两个人生日相同。这个结论看起来不可思议,所以被称为悖论。按照直觉,要得到 50% 的概率,起码得有 183 个人吧,因为一年有 365 天呀?其实不是的,觉得这个结论不可思议主要有两个思维误区:
第一个误区是误解「存在」这个词的含义。
读者可能认为,如果 23 个人中出现相同生日的概率就能达到 50%,是不是意味着:
假设现在屋子里坐着 22 个人,然后我走进去,那么有 50% 的概率我可以找到一个人和我生日相同。但这怎么可能呢?
并不是的,你这种想法是以自我为中心,而题目的概率是在描述整体。也就是说「存在」的含义是指 23 人中的任意两个人,涉及排列组合,大概率和你没啥关系。
如果你非要计算存在和自己生日相同的人的概率是多少,可以这样计算:
1 - P(22 个人都和我的生日不同) = 1 -(364/365)^22 = 0.06
这样计算得到的结果是不是看起来合理多了?生日悖论计算对象的不是某一个人,而是一个整体,其中包含了所有人的排列组合,它们的概率之和当然会大得多。
第二个误区是认为概率是线性变化的。
读者可能认为,如果 23 个人中出现相同生日的概率就能达到 50%,是不是意味着 46 个人的概率就能达到 100%?
不是的,就像中奖率 50% 的游戏,你玩两次的中奖率就是 100% 吗?显然不是,你玩两次的中奖率是 75%:
$P(两次能中奖) = P(第一次就中了) + P(第一次没中但第二次中了) = 1/2 + 1/2*1/2 = 75%$
那么换到生日悖论也是一个道理,概率不是简单叠加,而要考虑一个连续的过程,所以这个结论并没有什么不合常理之处。
那为什么只要 23 个人出现相同生日的概率就能大于 50% 了呢?我们先计算 23 个人生日都唯一(不重复)的概率。只有 1 个人的时候,生日唯一的概率是 $365/365$,2 个人时,生日唯一的概率是 $365/365 × 364/365$,以此类推可知 23 人的生日都唯一的概率:

算出来大约是 0.493,所以存在相同生日的概率就是 0.507,差不多就是 50% 了。实际上,按照这个算法,当人数达到 70 时,存在两个人生日相同的概率就上升到了 99.9%,基本可以认为是 100% 了。所以从概率上说,一个几十人的小团体中存在生日相同的人真没啥稀奇的。

三、三门问题

这个游戏很经典了:游戏参与者面对三扇门,其中两扇门后面是山羊,一扇门后面是跑车。参与者只要随便选一扇门,门后面的东西就归他(跑车的价值当然更大)。但是主持人决定帮一下参与者:在他选择之后,先不急着打开这扇门,而是由主持人打开剩下两扇门中的一扇,展示其中的山羊(主持人知道每扇门后面是什么),然后给参与者一次换门的机会,此时参与者应该换门还是不换门呢?
为了防止第一次看到这个问题的读者迷惑,再具体描述一下这个问题:
你是游戏参与者,现在有门 1,2,3,假设你随机选择了门 1,然后主持人打开了门 3 告诉你那后面是山羊。现在,你是坚持你最初的选择门 1,还是选择换成门 2 呢?

答案是应该换门,换门之后抽到跑车的概率是 2/3,不换的话是 1/3。又一次反直觉,感觉换不换的中奖概率应该都一样啊,因为最后肯定就剩两个门,一个是羊,一个是跑车,这是事实,所以不管选哪个的概率不都是 1/2 吗?
类似前面说的男孩女孩问题,最简单稳妥的方法就是把所有可能结果穷举出来:
穷举树
很容易看到选择换门中奖的概率是 2/3,不换的话是 1/3。
关于这个问题还有更简单的方法:主持人开门实际上在「浓缩」概率。一开始你选择到跑车的概率当然是 1/3,剩下两个门中包含跑车的概率当然是 2/3,这没啥可说的。但是主持人帮你排除了一个含有山羊的门,相当于把那 2/3 的概率浓缩到了剩下的这一扇门上。那么,你说你是抱着原来那扇 1/3 的门,还是换成那扇经过「浓缩」的 2/3 概率的门呢?
再直观一点,假设你三选一,剩下 2 扇门,再给你加入 98 扇装山羊的门,把这 100 扇门随机打乱,问你换不换?肯定不换对吧,这明摆着把概率稀释了,肯定抱着原来的那扇门是最可能中跑车的。再假设,初始有 100 扇门,你选了一扇,然后主持人在剩下 99 扇门中帮你排除 98 个山羊,问你换不换一扇门?肯定换对吧,你手上那扇门是 1%,另一扇门是 99%,或者也可以这样理解,不换只是选择了 1 扇门,换门相当于选择了 99 扇门,这样结果很明显了吧?
以上思想,也许有的读者都思考过,下面我们思考这样一个问题:假设你在决定是否换门的时候,小明破门而入,要求帮你做出选择。他完全不知道之前发生的事,他只知道面前有两扇门,一扇是跑车一扇是山羊,那么他抽中跑车的概率是多大?
当然是 1/2,这也是很多人做错三门问题的根本原因。类似生日悖论,人们总是容易以自我为中心,通过这个小明的视角来计算是否换门,这显然会进入误区。
就好比有两个箱子,一号箱子有 4 个黑球 2 个红球,二号箱子有 2 个黑球 4 个红球,随便选一个箱子,随便摸一个球,问你摸出红球的概率。
对于不知情的小明,他会随机选择一个箱子,随机摸球,摸到红球的概率是:1/2 × 2/6 + 1/2 × 4/6 = 1/2
对于知情的你,你知道在二号箱子摸球概率大,所以只在二号箱摸,摸到红球的概率是:0 × 2/6 + 1 × 4/6 = 2/3
三门问题是有指导意义的。比如你蒙选择题,先蒙了 A,后来灵机一动排除了 B 和 C,请问你是否要把 A 换成 D?答案是,换!
也许读者会问,如果只排除了一个答案,比如说 B,那么我是否应该把 A 换成 C 或者 D 呢?答案是,换!
因为按照刚才「浓缩」概率这个思想,只要进行了排除,都是在进行「浓缩」,均摊下来肯定比你一开始蒙的那个答案概率 1/4 高。比如刚才的例子,C 和 D 的正确概率都是 3/8,而你开始蒙的 A 只有 1/4。
当然,运用此策略蒙题的前提是你真的抓瞎,真的随机乱选答案,这样概率才能作为最后的杀手锏。

上一篇:信封嵌套问题
下一篇:洗牌算法
目录

前缀和技巧

今天来聊一道简单却十分巧妙的算法问题:算出一共有几个和为 k 的子数组。

那我把所有子数组都穷举出来,算它们的和,看看谁的和等于 k 不就行了。
关键是,如何快速得到某个子数组的和呢,比如说给你一个数组 nums,让你实现一个接口 sum(i, j),这个接口要返回 nums[i..j] 的和,而且会被多次调用,你怎么实现这个接口呢?
因为接口要被多次调用,显然不能每次都去遍历 nums[i..j],有没有一种快速的方法在 O(1) 时间内算出 nums[i..j] 呢?这就需要前缀和技巧了。

一、什么是前缀和

前缀和的思路是这样的,对于一个给定的数组 nums,我们额外开辟一个前缀和数组进行预处理:

1
2
3
4
5
6
int n = nums.length;
// 前缀和数组
int[] preSum = new int[n + 1];
preSum[0] = 0;
for (int i = 0; i < n; i++)
preSum[i + 1] = preSum[i] + nums[i];


这个前缀和数组 preSum 的含义也很好理解,preSum[i] 就是 nums[0..i-1] 的和。那么如果我们想求 nums[i..j] 的和,只需要一步操作 preSum[j+1]-preSum[i] 即可,而不需要重新去遍历数组了。
回到这个子数组问题,我们想求有多少个子数组的和为 k,借助前缀和技巧很容易写出一个解法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
int subarraySum(int[] nums, int k) {
int n = nums.length;
// 构造前缀和
int[] sum = new int[n + 1];
sum[0] = 0;
for (int i = 0; i < n; i++)
sum[i + 1] = sum[i] + nums[i];

int ans = 0;
// 穷举所有子数组
for (int i = 1; i <= n; i++)
for (int j = 0; j < i; j++)
// sum of nums[j..i-1]
if (sum[i] - sum[j] == k)
ans++;
return ans;
}

这个解法的时间复杂度 $O(N^2)$ 空间复杂度 $O(N)$,并不是最优的解法。不过通过这个解法理解了前缀和数组的工作原理之后,可以使用一些巧妙的办法把时间复杂度进一步降低。

二、优化解法

前面的解法有嵌套的 for 循环:

1
2
3
4
for (int i = 1; i <= n; i++)
for (int j = 0; j < i; j++)
if (sum[i] - sum[j] == k)
ans++;

第二层 for 循环在干嘛呢?翻译一下就是,在计算,有几个 j 能够使得 sum[i]sum[j] 的差为 k。毎找到一个这样的 j,就把结果加一。
我们可以把 if 语句里的条件判断移项,这样写:

1
2
if (sum[j] == sum[i] - k)
ans++;

优化的思路是:我直接记录下有几个 sum[j]sum[i] - k 相等,直接更新结果,就避免了内层的 for 循环。我们可以用哈希表,在记录前缀和的同时记录该前缀和出现的次数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
int subarraySum(int[] nums, int k) {
int n = nums.length;
// map:前缀和 -> 该前缀和出现的次数
HashMap<Integer, Integer>
preSum = new HashMap<>();
// base case
preSum.put(0, 1);
int ans = 0, sum0_i = 0;
for (int i = 0; i < n; i++) {
sum0_i += nums[i];
// 这是我们想找的前缀和 nums[0..j]
int sum0_j = sum0_i - k;
// 如果前面有这个前缀和,则直接更新答案
if (preSum.containsKey(sum0_j))
ans += preSum.get(sum0_j);
// 把前缀和 nums[0..i] 加入并记录出现次数
preSum.put(sum0_i,
preSum.getOrDefault(sum0_i, 0) + 1);
}
return ans;
}

比如说下面这个情况,需要前缀和 8 就能找到和为 k 的子数组了,之前的暴力解法需要遍历数组去数有几个 8,而优化解法借助哈希表可以直接得知有几个前缀和为 8。

这样,就把时间复杂度降到了 $O(N)$,是最优解法了。

三、总结

前缀和不难,却很有用,主要用于处理数组区间的问题。
比如说,让你统计班上同学考试成绩在不同分数段的百分比,也可以利用前缀和技巧:

1
2
3
4
5
6
7
8
9
int[] scores; // 存储着所有同学的分数
// 试卷满分 150 分
int[] count = new int[150 + 1]
// 记录每个分数有几个同学
for (int score : scores)
count[score]++
// 构造前缀和
for (int i = 1; i < count.length; i++)
count[i] = count[i] + count[i-1];

这样,给你任何一个分数段,你都能通过前缀和相减快速计算出这个分数段的人数,百分比也就很容易计算了。
但是,稍微复杂一些的算法问题,不止考察简单的前缀和技巧。比如本文探讨的这道题目,就需要借助前缀和的思路做进一步的优化,借助哈希表去除不必要的嵌套循环。可见对题目的理解和细节的分析能力对于算法的优化是至关重要的。
希望本文对你有帮助。

上一篇:烧饼排序
下一篇:字符串乘法
目录