修改README.md,将目录先改成英文

This commit is contained in:
labuladong
2020-02-28 16:27:10 +08:00
parent f91fc9ca55
commit b00ec6300c
55 changed files with 12 additions and 121 deletions

7
data_structure/README.md Normal file
View File

@ -0,0 +1,7 @@
# 数据结构系列
这一章主要是一些特殊的数据结构设计,比如单调栈解决 Next Greater Number单调队列解决滑动窗口问题还有常用数据结构的操作比如链表、树、二叉堆。
欢迎关注我的公众号 labuladong方便获得最新的优质文章
![labuladong二维码](../pictures/qrcode.jpg)

View File

@ -0,0 +1,216 @@
# 二叉堆详解实现优先级队列
二叉堆Binary Heap没什么神秘性质比二叉搜索树 BST 还简单。其主要操作就两个,`sink`(下沉)和 `swim`(上浮),用以维护二叉堆的性质。其主要应用有两个,首先是一种排序方法「堆排序」,第二是一种很有用的数据结构「优先级队列」。
本文就以实现优先级队列Priority Queue为例通过图片和人类的语言来描述一下二叉堆怎么运作的。
### 一、二叉堆概览
首先,二叉堆和二叉树有啥关系呢,为什么人们总数把二叉堆画成一棵二叉树?
因为,二叉堆其实就是一种特殊的二叉树(完全二叉树),只不过存储在数组里。一般的链表二叉树,我们操作节点的指针,而在数组里,我们把数组索引作为指针:
```java
// 父节点的索引
int parent(int root) {
return root / 2;
}
// 左孩子的索引
int left(int root) {
return root * 2;
}
// 右孩子的索引
int right(int root) {
return root * 2 + 1;
}
```
画个图你立即就能理解了,注意数组的第一个索引 0 空着不用,
![1](../pictures/heap/1.png)
PS因为数组索引是数组为了方便区分将字符作为数组元素。
你看到了,把 arr[1] 作为整棵树的根的话,每个节点的父节点和左右孩子的索引都可以通过简单的运算得到,这就是二叉堆设计的一个巧妙之处。为了方便讲解,下面都会画的图都是二叉树结构,相信你能把树和数组对应起来。
二叉堆还分为最大堆和最小堆。**最大堆的性质是:每个节点都大于等于它的两个子节点。**类似的,最小堆的性质是:每个节点都小于等于它的子节点。
两种堆核心思路都是一样的,本文以最大堆为例讲解。
对于一个最大堆,根据其性质,显然堆顶,也就是 arr[1] 一定是所有元素中最大的元素。
### 二、优先级队列概览
优先级队列这种数据结构有一个很有用的功能,你插入或者删除元素的时候,元素会自动排序,这底层的原理就是二叉堆的操作。
数据结构的功能无非增删查该,优先级队列有两个主要 API分别是 `insert` 插入一个元素和 `delMax` 删除最大元素(如果底层用最小堆,那么就是 `delMin`)。
下面我们实现一个简化的优先级队列,先看下代码框架:
PS为了清晰起见这里用到 Java 的泛型,`Key` 可以是任何一种可比较大小的数据类型,你可以认为它是 int、char 等。
```java
public class MaxPQ
<Key extends Comparable<Key>> {
// 存储元素的数组
private Key[] pq;
// 当前 Priority Queue 中的元素个数
private int N = 0;
public MaxPQ(int cap) {
// 索引 0 不用,所以多分配一个空间
pq = (Key[]) new Comparable[cap + 1];
}
/* 返回当前队列中最大元素 */
public Key max() {
return pq[1];
}
/* 插入元素 e */
public void insert(Key e) {...}
/* 删除并返回当前队列中最大元素 */
public Key delMax() {...}
/* 上浮第 k 个元素,以维护最大堆性质 */
private void swim(int k) {...}
/* 下沉第 k 个元素,以维护最大堆性质 */
private void sink(int k) {...}
/* 交换数组的两个元素 */
private void exch(int i, int j) {
Key temp = pq[i];
pq[i] = pq[j];
pq[j] = temp;
}
/* pq[i] 是否比 pq[j] 小? */
private boolean less(int i, int j) {
return pq[i].compareTo(pq[j]) < 0;
}
/* 还有 left, right, parent 三个方法 */
}
```
空出来的四个方法是二叉堆和优先级队列的奥妙所在,下面用图文来逐个理解。
### 三、实现 swim 和 sink
为什么要有上浮 swim 和下沉 sink 的操作呢?为了维护堆结构。
我们要讲的是最大堆,每个节点都比它的两个子节点大,但是在插入元素和删除元素时,难免破坏堆的性质,这就需要通过这两个操作来恢复堆的性质了。
对于最大堆,会破坏堆性质的有有两种情况:
1. 如果某个节点 A 比它的子节点(中的一个)小,那么 A 就不配做父节点,应该下去,下面那个更大的节点上来做父节点,这就是对 A 进行**下沉**。
2. 如果某个节点 A 比它的父节点大,那么 A 不应该做子节点,应该把父节点换下来,自己去做父节点,这就是对 A 的**上浮**。
当然,错位的节点 A 可能要上浮(或下沉)很多次,才能到达正确的位置,恢复堆的性质。所以代码中肯定有一个 `while` 循环。
细心的读者也许会问,这两个操作不是互逆吗,所以上浮的操作一定能用下沉来完成,为什么我还要费劲写两个方法?
是的,操作是互逆等价的,但是最终我们的操作只会在堆底和堆顶进行(等会讲原因),显然堆底的「错位」元素需要上浮,堆顶的「错位」元素需要下沉。
**上浮的代码实现:**
```java
private void swim(int k) {
// 如果浮到堆顶,就不能再上浮了
while (k > 1 && less(parent(k), k)) {
// 如果第 k 个元素比上层大
// 将 k 换上去
exch(parent(k), k);
k = parent(k);
}
}
```
画个 GIF 看一眼就明白了:
![2](../pictures/heap/swim.gif)
**下沉的代码实现:**
下沉比上浮略微复杂一点,因为上浮某个节点 A只需要 A 和其父节点比较大小即可;但是下沉某个节点 A需要 A 和其**两个子节点**比较大小,如果 A 不是最大的就需要调整位置,要把较大的那个子节点和 A 交换。
```java
private void sink(int k) {
// 如果沉到堆底,就沉不下去了
while (left(k) <= N) {
// 先假设左边节点较大
int older = left(k);
// 如果右边节点存在,比一下大小
if (right(k) <= N && less(older, right(k)))
older = right(k);
// 结点 k 比俩孩子都大,就不必下沉了
if (less(older, k)) break;
// 否则,不符合最大堆的结构,下沉 k 结点
exch(k, older);
k = older;
}
}
```
画个 GIF 看下就明白了:
![3](../pictures/heap/sink.gif)
至此,二叉堆的主要操作就讲完了,一点都不难吧,代码加起来也就十行。明白了 `sink``swim` 的行为,下面就可以实现优先级队列了。
### 四、实现 delMax 和 insert
这两个方法就是建立在 `swim``sink` 上的。
**`insert` 方法先把要插入的元素添加到堆底的最后,然后让其上浮到正确位置。**
![4](../pictures/heap/insert.gif)
```java
public void insert(Key e) {
N++;
// 先把新元素加到最后
pq[N] = e;
// 然后让它上浮到正确的位置
swim(N);
}
```
**`delMax` 方法先把堆顶元素 A 和堆底最后的元素 B 对调,然后删除 A最后让 B 下沉到正确位置。**
```java
public Key delMax() {
// 最大堆的堆顶就是最大元素
Key max = pq[1];
// 把这个最大元素换到最后,删除之
exch(1, N);
pq[N] = null;
N--;
// 让 pq[1] 下沉到正确位置
sink(1);
return max;
}
```
![5](../pictures/heap/delete.gif)
至此,一个优先级队列就实现了,插入和删除元素的时间复杂度为 $O(logK)$$K$ 为当前二叉堆(优先级队列)中的元素总数。因为我们时间复杂度主要花费在 `sink` 或者 `swim` 上,而不管上浮还是下沉,最多也就树(堆)的高度,也就是 log 级别。
### 五、最后总结
二叉堆就是一种完全二叉树,所以适合存储在数组中,而且二叉堆拥有一些特殊性质。
二叉堆的操作很简单,主要就是上浮和下沉,来维护堆的性质(堆有序),核心代码也就十行。
优先级队列是基于二叉堆实现的,主要操作是插入和删除。插入是先插到最后,然后上浮到正确位置;删除是调换位置后再删除,然后下沉到正确位置。核心代码也就十行。
也许这就是数据结构的威力,简单的操作就能实现巧妙的功能,真心佩服发明二叉堆算法的人!
**致力于把算法讲清楚!欢迎关注我的微信公众号 labuladong查看更多通俗易懂的文章**
![labuladong](../pictures/labuladong.png)

View File

@ -0,0 +1,277 @@
# 二叉搜索树操作集锦
通过之前的文章[框架思维](../算法思维系列/学习数据结构和算法的高效方法.md),二叉树的遍历框架应该已经印到你的脑子里了,这篇文章就来实操一下,看看框架思维是怎么灵活运用,秒杀一切二叉树问题的。
二叉树算法的设计的总路线:明确一个节点要做的事情,然后剩下的事抛给框架。
```java
void traverse(TreeNode root) {
// root 需要做什么?在这做。
// 其他的不用 root 操心,抛给框架
traverse(root.left);
traverse(root.right);
}
```
举两个简单的例子体会一下这个思路,热热身。
**1. 如何把二叉树所有的节点中的值加一?**
```java
void plusOne(TreeNode root) {
if (root == null) return;
root.val += 1;
plusOne(root.left);
plusOne(root.right);
}
```
**2. 如何判断两棵二叉树是否完全相同?**
```java
boolean isSameTree(TreeNode root1, TreeNode root2) {
// 都为空的话,显然相同
if (root1 == null && root2 == null) return true;
// 一个为空,一个非空,显然不同
if (root1 == null || root2 == null) return false;
// 两个都非空,但 val 不一样也不行
if (root1.val != root2.val) return false;
// root1 和 root2 该比的都比完了
return isSameTree(root1.left, root2.left)
&& isSameTree(root1.right, root2.right);
}
```
借助框架,上面这两个例子不难理解吧?如果可以理解,那么所有二叉树算法你都能解决。
二叉搜索树Binary Search Tree简称 BST是一种很常用的的二叉树。它的定义是一个二叉树中任意节点的值要大于等于左子树所有节点的值且要小于等于右边子树的所有节点的值。
如下就是一个符合定义的 BST
![BST](../pictures/BST/BST_example.png)
下面实现 BST 的基础操作:判断 BST 的合法性、增、删、查。其中“删”和“判断合法性”略微复杂。
**零、判断 BST 的合法性**
这里是有坑的哦,我们按照刚才的思路,每个节点自己要做的事不就是比较自己和左右孩子吗?看起来应该这样写代码:
```java
boolean isValidBST(TreeNode root) {
if (root == null) return true;
if (root.left != null && root.val <= root.left.val) return false;
if (root.right != null && root.val >= root.right.val) return false;
return isValidBST(root.left)
&& isValidBST(root.right);
}
```
但是这个算法出现了错误BST 的每个节点应该要小于右边子树的所有节点,下面这个二叉树显然不是 BST但是我们的算法会把它判定为 BST。
![notBST](../pictures/BST/假BST.png)
出现错误,不要慌张,框架没有错,一定是某个细节问题没注意到。我们重新看一下 BST 的定义root 需要做的不只是和左右子节点比较,而是要整个左子树和右子树所有节点比较。怎么办,鞭长莫及啊!
这种情况,我们可以使用辅助函数,增加函数参数列表,在参数中携带额外信息,请看正确的代码:
```java
boolean isValidBST(TreeNode root) {
return isValidBST(root, null, null);
}
boolean isValidBST(TreeNode root, TreeNode min, TreeNode max) {
if (root == null) return true;
if (min != null && root.val <= min.val) return false;
if (max != null && root.val >= max.val) return false;
return isValidBST(root.left, min, root)
&& isValidBST(root.right, root, max);
}
```
**一、在 BST 中查找一个数是否存在**
根据我们的指导思想,可以这样写代码:
```java
boolean isInBST(TreeNode root, int target) {
if (root == null) return false;
if (root.val == target) return true;
return isInBST(root.left, target)
|| isInBST(root.right, target);
}
```
这样写完全正确,充分证明了你的框架性思维已经养成。现在你可以考虑一点细节问题了:如何充分利用信息,把 BST 这个“左小右大”的特性用上?
很简单,其实不需要递归地搜索两边,类似二分查找思想,根据 target 和 root.val 的大小比较,就能排除一边。我们把上面的思路稍稍改动:
```java
boolean isInBST(TreeNode root, int target) {
if (root == null) return false;
if (root.val == target)
return true;
if (root.val < target)
return isInBST(root.right, target);
if (root.val > target)
return isInBST(root.left, target);
// root 该做的事做完了,顺带把框架也完成了,妙
}
```
于是,我们对原始框架进行改造,抽象出一套**针对 BST 的遍历框架**
```java
void BST(TreeNode root, int target) {
if (root.val == target)
// 找到目标,做点什么
if (root.val < target)
BST(root.right, target);
if (root.val > target)
BST(root.left, target);
}
```
**二、在 BST 中插入一个数**
对数据结构的操作无非遍历 + 访问,遍历就是“找”,访问就是“改”。具体到这个问题,插入一个数,就是先找到插入位置,然后进行插入操作。
上一个问题,我们总结了 BST 中的遍历框架,就是“找”的问题。直接套框架,加上“改”的操作即可。一旦涉及“改”,函数就要返回 TreeNode 类型,并且对递归调用的返回值进行接收。
```java
TreeNode insertIntoBST(TreeNode root, int val) {
// 找到空位置插入新节点
if (root == null) return new TreeNode(val);
// if (root.val == val)
// BST 中一般不会插入已存在元素
if (root.val < val)
root.right = insertIntoBST(root.right, val);
if (root.val > val)
root.left = insertIntoBST(root.left, val);
return root;
}
```
**三、在 BST 中删除一个数**
这个问题稍微复杂,不过你有框架指导,难不住你。跟插入操作类似,先“找”再“改”,先把框架写出来再说:
```java
TreeNode deleteNode(TreeNode root, int key) {
if (root.val == key) {
// 找到啦,进行删除
} else if (root.val > key) {
root.left = deleteNode(root.left, key);
} else if (root.val < key) {
root.right = deleteNode(root.right, key);
}
return root;
}
```
找到目标节点了,比方说是节点 A如何删除这个节点这是难点。因为删除节点的同时不能破坏 BST 的性质。有三种情况,用图片来说明。
情况 1A 恰好是末端节点,两个子节点都为空,那么它可以当场去世了。
图片来自 LeetCode
![1](../pictures/BST/bst_deletion_case_1.png)
```java
if (root.left == null && root.right == null)
return null;
```
情况 2A 只有一个非空子节点,那么它要让这个孩子接替自己的位置。
图片来自 LeetCode
![2](../pictures/BST/bst_deletion_case_2.png)
```java
// 排除了情况 1 之后
if (root.left == null) return root.right;
if (root.right == null) return root.left;
```
情况 3A 有两个子节点,麻烦了,为了不破坏 BST 的性质A 必须找到左子树中最大的那个节点,或者右子树中最小的那个节点来接替自己。我们以第二种方式讲解。
图片来自 LeetCode
![2](../pictures/BST/bst_deletion_case_3.png)
```java
if (root.left != null && root.right != null) {
// 找到右子树的最小节点
TreeNode minNode = getMin(root.right);
// 把 root 改成 minNode
root.val = minNode.val;
// 转而去删除 minNode
root.right = deleteNode(root.right, minNode.val);
}
```
三种情况分析完毕,填入框架,简化一下代码:
```java
TreeNode deleteNode(TreeNode root, int key) {
if (root == null) return null;
if (root.val == key) {
// 这两个 if 把情况 1 和 2 都正确处理了
if (root.left == null) return root.right;
if (root.right == null) return root.left;
// 处理情况 3
TreeNode minNode = getMin(root.right);
root.val = minNode.val;
root.right = deleteNode(root.right, minNode.val);
} else if (root.val > key) {
root.left = deleteNode(root.left, key);
} else if (root.val < key) {
root.right = deleteNode(root.right, key);
}
return root;
}
TreeNode getMin(TreeNode node) {
// BST 最左边的就是最小的
while (node.left != null) node = node.left;
return node;
}
```
删除操作就完成了。注意一下,这个删除操作并不完美,因为我们一般不会通过 root.val = minNode.val 修改节点内部的值来交换节点,而是通过一系列略微复杂的链表操作交换 root 和 minNode 两个节点。因为具体应用中val 域可能会很大,修改起来很耗时,而链表操作无非改一改指针,而不会去碰内部数据。
但这里忽略这个细节,旨在突出 BST 基本操作的共性,以及借助框架逐层细化问题的思维方式。
**四、最后总结**
通过这篇文章,你学会了如下几个技巧:
1. 二叉树算法设计的总路线:把当前节点要做的事做好,其他的交给递归框架,不用当前节点操心。
2. 如果当前节点会对下面的子节点有整体影响,可以通过辅助函数增长参数列表,借助参数传递信息。
3. 在二叉树框架之上,扩展出一套 BST 遍历框架:
```java
void BST(TreeNode root, int target) {
if (root.val == target)
// 找到目标,做点什么
if (root.val < target)
BST(root.right, target);
if (root.val > target)
BST(root.left, target);
}
```
4. 掌握了 BST 的基本操作。
坚持原创高质量文章,致力于把算法问题讲清楚,欢迎关注我的公众号 labuladong 获取最新文章:
![labuladong](../pictures/labuladong.jpg)

119
data_structure/单调栈.md Normal file
View File

@ -0,0 +1,119 @@
### 如何使用单调栈解题
stack是很简单的一种数据结构先进后出的逻辑顺序符合某些问题的特点比如说函数调用栈。
单调栈实际上就是栈,只是利用了一些巧妙的逻辑,使得每次新元素入栈后,栈内的元素都保持有序(单调递增或单调递减)。
听起来有点像堆heap不是的单调栈用途不太广泛只处理一种典型的问题叫做 Next Greater Element。本文用讲解单调队列的算法模版解决这类问题并且探讨处理「循环数组」的策略。
首先,讲解 Next Greater Number 的原始问题:给你一个数组,返回一个等长的数组,对应索引存储着下一个更大元素,如果没有更大的元素,就存 -1。不好用语言解释清楚直接上一个例子
给你一个数组 [2,1,2,4,3],你返回数组 [4,2,4,-1,-1]。
解释:第一个 2 后面比 2 大的数是 4; 1 后面比 1 大的数是 2第二个 2 后面比 2 大的数是 4; 4 后面没有比 4 大的数,填 -13 后面没有比 3 大的数,填 -1。
这道题的暴力解法很好想到,就是对每个元素后面都进行扫描,找到第一个更大的元素就行了。但是暴力解法的时间复杂度是 O(n^2)。
这个问题可以这样抽象思考把数组的元素想象成并列站立的人元素大小想象成人的身高。这些人面对你站成一列如何求元素「2」的 Next Greater Number 呢很简单如果能够看到元素「2」那么他后面可见的第一个人就是「2」的 Next Greater Number因为比「2」小的元素身高不够都被「2」挡住了第一个露出来的就是答案。
![ink-image](../pictures/%E5%8D%95%E8%B0%83%E6%A0%88/1.png)
这个情景很好理解吧?带着这个抽象的情景,先来看下代码。
```cpp
vector<int> nextGreaterElement(vector<int>& nums) {
vector<int> ans(nums.size()); // 存放答案的数组
stack<int> s;
for (int i = nums.size() - 1; i >= 0; i--) { // 倒着往栈里放
while (!s.empty() && s.top() <= nums[i]) { // 判定个子高矮
s.pop(); // 矮个起开,反正也被挡着了。。。
}
ans[i] = s.empty() ? -1 : s.top(); // 这个元素身后的第一个高个
s.push(nums[i]); // 进队,接受之后的身高判定吧!
}
return ans;
}
```
这就是单调队列解决问题的模板。for 循环要从后往前扫描元素因为我们借助的是栈的结构倒着入栈其实是正着出栈。while 循环是把两个“高个”元素之间的元素排除,因为他们的存在没有意义,前面挡着个“更高”的元素,所以他们不可能被作为后续进来的元素的 Next Great Number 了。
这个算法的时间复杂度不是那么直观,如果你看到 for 循环嵌套 while 循环,可能认为这个算法的复杂度也是 O(n^2),但是实际上这个算法的复杂度只有 O(n)。
分析它的时间复杂度,要从整体来看:总共有 n 个元素,每个元素都被 push 入栈了一次,而最多会被 pop 一次,没有任何冗余操作。所以总的计算规模是和元素规模 n 成正比的,也就是 O(n) 的复杂度。
现在,你已经掌握了单调栈的使用技巧,来一个简单的变形来加深一下理解。
给你一个数组 T = [73, 74, 75, 71, 69, 72, 76, 73],这个数组存放的是近几天的天气气温(这气温是铁板烧?不是的,这里用的华氏度)。你返回一个数组,计算:对于每一天,你还要至少等多少天才能等到一个更暖和的气温;如果等不到那一天,填 0 。
举例:给你 T = [73, 74, 75, 71, 69, 72, 76, 73],你返回 [1, 1, 4, 2, 1, 1, 0, 0]。
解释:第一天 73 华氏度,第二天 74 华氏度,比 73 大,所以对于第一天,只要等一天就能等到一个更暖和的气温。后面的同理。
你已经对 Next Greater Number 类型问题有些敏感了,这个问题本质上也是找 Next Greater Number只不过现在不是问你 Next Greater Number 是多少,而是问你当前距离 Next Greater Number 的距离而已。
相同类型的问题,相同的思路,直接调用单调栈的算法模板,稍作改动就可以啦,直接上代码把。
```cpp
vector<int> dailyTemperatures(vector<int>& T) {
vector<int> ans(T.size());
stack<int> s; // 这里放元素索引,而不是元素
for (int i = T.size() - 1; i >= 0; i--) {
while (!s.empty() && T[s.top()] <= T[i]) {
s.pop();
}
ans[i] = s.empty() ? 0 : (s.top() - i); // 得到索引间距
s.push(i); // 加入索引,而不是元素
}
return ans;
}
```
单调栈讲解完毕。下面开始另一个重点:如何处理「循环数组」。
同样是 Next Greater Number现在假设给你的数组是个环形的如何处理
给你一个数组 [2,1,2,4,3],你返回数组 [4,2,4,-1,4]。拥有了环形属性,最后一个元素 3 绕了一圈后找到了比自己大的元素 4 。
![ink-image](../pictures/%E5%8D%95%E8%B0%83%E6%A0%88/2.png)
首先,计算机的内存都是线性的,没有真正意义上的环形数组,但是我们可以模拟出环形数组的效果,一般是通过 % 运算符求模(余数),获得环形特效:
```java
int[] arr = {1,2,3,4,5};
int n = arr.length, index = 0;
while (true) {
print(arr[index % n]);
index++;
}
```
回到 Next Greater Number 的问题,增加了环形属性后,问题的难点在于:这个 Next 的意义不仅仅是当前元素的右边了,有可能出现在当前元素的左边(如上例)。
明确问题,问题就已经解决了一半了。我们可以考虑这样的思路:将原始数组“翻倍”,就是在后面再接一个原始数组,这样的话,按照之前“比身高”的流程,每个元素不仅可以比较自己右边的元素,而且也可以和左边的元素比较了。
![ink-image (2)](../pictures/%E5%8D%95%E8%B0%83%E6%A0%88/3.png)
怎么实现呢?你当然可以把这个双倍长度的数组构造出来,然后套用算法模板。但是,我们可以不用构造新数组,而是利用循环数组的技巧来模拟。直接看代码吧:
```cpp
vector<int> nextGreaterElements(vector<int>& nums) {
int n = nums.size();
vector<int> res(n); // 存放结果
stack<int> s;
// 假装这个数组长度翻倍了
for (int i = 2 * n - 1; i >= 0; i--) {
while (!s.empty() && s.top() <= nums[i % n])
s.pop();
res[i % n] = s.empty() ? -1 : s.top();
s.push(nums[i % n]);
}
return res;
}
```
至此,你已经掌握了单调栈的设计方法及代码模板,学会了解决 Next Greater Number并能够处理循环数组了。
你的在看是对我的鼓励。关注公众号labuladong

View File

@ -0,0 +1,185 @@
# 特殊数据结构:单调队列
前文讲了一种特殊的数据结构「单调栈」monotonic stack解决了一类问题「Next Greater Number」本文写一个类似的数据结构「单调队列」。
也许这种数据结构的名字你没听过,其实没啥难的,就是一个「队列」,只是使用了一点巧妙的方法,使得队列中的元素单调递增(或递减)。这个数据结构有什么用?可以解决滑动窗口的一系列问题。
看一道 LeetCode 题目,难度 hard
![](../pictures/单调队列/title.png)
### 一、搭建解题框架
这道题不复杂,难点在于如何在 O(1) 时间算出每个「窗口」中的最大值,使得整个算法在线性时间完成。在之前我们探讨过类似的场景,得到一个结论:
在一堆数字中,已知最值,如果给这堆数添加一个数,那么比较一下就可以很快算出最值;但如果减少一个数,就不一定能很快得到最值了,而要遍历所有数重新找最值。
回到这道题的场景,每个窗口前进的时候,要添加一个数同时减少一个数,所以想在 O(1) 的时间得出新的最值,就需要「单调队列」这种特殊的数据结构来辅助了。
一个普通的队列一定有这两个操作:
```java
class Queue {
void push(int n);
// 或 enqueue在队尾加入元素 n
void pop();
// 或 dequeue删除队头元素
}
```
一个「单调队列」的操作也差不多:
```java
class MonotonicQueue {
// 在队尾添加元素 n
void push(int n);
// 返回当前队列中的最大值
int max();
// 队头元素如果是 n删除它
void pop(int n);
}
```
当然,这几个 API 的实现方法肯定跟一般的 Queue 不一样,不过我们暂且不管,而且认为这几个操作的时间复杂度都是 O(1),先把这道「滑动窗口」问题的解答框架搭出来:
```cpp
vector<int> maxSlidingWindow(vector<int>& nums, int k) {
MonotonicQueue window;
vector<int> res;
for (int i = 0; i < nums.size(); i++) {
if (i < k - 1) { //先把窗口的前 k - 1 填满
window.push(nums[i]);
} else { // 窗口开始向前滑动
window.push(nums[i]);
res.push_back(window.max());
window.pop(nums[i - k + 1]);
// nums[i - k + 1] 就是窗口最后的元素
}
}
return res;
}
```
![图示](../pictures/单调队列/1.png)
这个思路很简单,能理解吧?下面我们开始重头戏,单调队列的实现。
### 二、实现单调队列数据结构
首先我们要认识另一种数据结构deque即双端队列。很简单
```java
class deque {
// 在队头插入元素 n
void push_front(int n);
// 在队尾插入元素 n
void push_back(int n);
// 在队头删除元素
void pop_front();
// 在队尾删除元素
void pop_back();
// 返回队头元素
int front();
// 返回队尾元素
int back();
}
```
而且,这些操作的复杂度都是 O(1)。这其实不是啥稀奇的数据结构,用链表作为底层结构的话,很容易实现这些功能。
「单调队列」的核心思路和「单调栈」类似。单调队列的 push 方法依然在队尾添加元素,但是要把前面比新元素小的元素都删掉:
```cpp
class MonotonicQueue {
private:
deque<int> data;
public:
void push(int n) {
while (!data.empty() && data.back() < n)
data.pop_back();
data.push_back(n);
}
};
```
你可以想象,加入数字的大小代表人的体重,把前面体重不足的都压扁了,直到遇到更大的量级才停住。
![](../pictures/单调队列/2.png)
如果每个元素被加入时都这样操作,最终单调队列中的元素大小就会保持一个单调递减的顺序,因此我们的 max() API 可以可以这样写:
```cpp
int max() {
return data.front();
}
```
pop() API 在队头删除元素 n也很好写
```cpp
void pop(int n) {
if (!data.empty() && data.front() == n)
data.pop_front();
}
```
之所以要判断 `data.front() == n`,是因为我们想删除的队头元素 n 可能已经被「压扁」了,这时候就不用删除了:
![](../pictures/单调队列/3.png)
至此,单调队列设计完毕,看下完整的解题代码:
```cpp
class MonotonicQueue {
private:
deque<int> data;
public:
void push(int n) {
while (!data.empty() && data.back() < n)
data.pop_back();
data.push_back(n);
}
int max() { return data.front(); }
void pop(int n) {
if (!data.empty() && data.front() == n)
data.pop_front();
}
};
vector<int> maxSlidingWindow(vector<int>& nums, int k) {
MonotonicQueue window;
vector<int> res;
for (int i = 0; i < nums.size(); i++) {
if (i < k - 1) { //先填满窗口的前 k - 1
window.push(nums[i]);
} else { // 窗口向前滑动
window.push(nums[i]);
res.push_back(window.max());
window.pop(nums[i - k + 1]);
}
}
return res;
}
```
**三、算法复杂度分析**
读者可能疑惑push 操作中含有 while 循环,时间复杂度不是 O(1) 呀,那么本算法的时间复杂度应该不是线性时间吧?
单独看 push 操作的复杂度确实不是 O(1),但是算法整体的复杂度依然是 O(N) 线性时间。要这样想nums 中的每个元素最多被 push_back 和 pop_back 一次,没有任何多余操作,所以整体的复杂度还是 O(N)。
空间复杂度就很简单了,就是窗口的大小 O(k)。
**四、最后总结**
有的读者可能觉得「单调队列」和「优先级队列」比较像,实际上差别很大的。
单调队列在添加元素的时候靠删除元素保持队列的单调性,相当于抽取出某个函数中单调递增(或递减)的部分;而优先级队列(二叉堆)相当于自动排序,差别大了去了。
赶紧去拿下 LeetCode 第 239 道题吧~
坚持原创高质量文章,致力于把算法问题讲清楚,欢迎关注我的公众号 labuladong 获取最新文章:
![labuladong](../pictures/labuladong.jpg)

View File

@ -0,0 +1,275 @@
# 拆解复杂问题:实现计算器
我们最终要实现的计算器功能如下:
1、输入一个字符串可以包含`+ - * /`、数字、括号以及空格,你的算法返回运算结构。
2、要符合运算法则括号的优先级最高先乘除后加减。
3、除号是整数除法无论正负都向 0 取整5/2=2-5/2=-2
4、可以假定输入的算式一定合法且计算过程不会出现整型溢出不会出现除数为 0 的意外情况。
比如输入如下字符串,算法会返回 9
`3 * (2-6 /(3 -7))`
可以看到,这就已经非常接近我们实际生活中使用的计算器了,虽然我们以前肯定都用过计算器,但是如果简单思考一下其算法实现,就会大惊失色:
1、按照常理处理括号要先计算最内层的括号然后向外慢慢化简。这个过程我们手算都容易出错何况写成算法呢
2、要做到先乘除后加减这一点教会小朋友还不算难但教给计算机恐怕有点困难。
3、要处理空格。我们为了美观习惯性在数字和运算符之间打个空格但是计算之中得想办法忽略这些空格。
我记得很多大学数据结构的教材上,在讲栈这种数据结构的时候,应该都会用计算器举例,但是有一说一,讲的真的垃圾,不知道多少未来的计算机科学家就被这种简单的数据结构劝退了。
那么本文就来聊聊怎么实现上述一个功能完备的计算器功能,**关键在于层层拆解问题,化整为零,逐个击破**,相信这种思维方式能帮大家解决各种复杂问题。
下面就来拆解,从最简单的一个问题开始。
### 一、字符串转整数
是的,就是这么一个简单的问题,首先告诉我,怎么把一个字符串形式的**正**整数,转化成 int 型?
```cpp
string s = "458";
int n = 0;
for (int i = 0; i < s.size(); i++) {
char c = s[i];
n = 10 * n + (c - '0');
}
// n 现在就等于 458
```
这个还是很简单的吧,老套路了。但是即便这么简单,依然有坑:**`(c - '0')`的这个括号不能省略,否则可能造成整型溢出**。
因为变量`c`是一个 ASCII 码,如果不加括号就会先加后减,想象一下`s`如果接近 INT_MAX就会溢出。所以用括号保证先减后加才行。
### 二、处理加减法
现在进一步,**如果输入的这个算式只包含加减法,而且不存在空格**,你怎么计算结果?我们拿字符串算式`1-12+3`为例,来说一个很简单的思路:
1、先给第一个数字加一个默认符号`+`,变成`+1-12+3`
2、把一个运算符和数字组合成一对儿也就是三对儿`+1``-12``+3`,把它们转化成数字,然后放到一个栈中。
3、将栈中所有的数字求和就是原算式的结果。
我们直接看代码,结合一张图就看明白了:
```cpp
int calculate(string s) {
stack<int> stk;
// 记录算式中的数字
int num = 0;
// 记录 num 前的符号,初始化为 +
char sign = '+';
for (int i = 0; i < s.size(); i++) {
char c = s[i];
// 如果是数字,连续读取到 num
if (isdigit(c))
num = 10 * num + (c - '0');
// 如果不是数字,就是遇到了下一个符号,
// 之前的数字和符号就要存进栈中
if (!isdigit(c) || i == s.size() - 1) {
switch (sign) {
case '+':
stk.push(num); break;
case '-':
stk.push(-num); break;
}
// 更新符号为当前符号,数字清零
sign = c;
num = 0;
}
}
// 将栈中所有结果求和就是答案
int res = 0;
while (!stk.empty()) {
res += stk.top();
stk.pop();
}
return res;
}
```
我估计就是中间带`switch`语句的部分有点不好理解吧,`i`就是从左到右扫描,`sign``num`跟在它身后。当`s[i]`遇到一个运算符时,情况是这样的:
![](../pictures/calculator/1.jpg)
所以说,此时要根据`sign`的 case 不同选择`nums`的正负号,存入栈中,然后更新`sign`并清零`nums`记录下一对儿符合和数字的组合。
另外注意,不只是遇到新的符号会触发入栈,当`i`走到了算式的尽头(`i == s.size() - 1`),也应该将前面的数字入栈,方便后续计算最终结果。
![](../pictures/calculator/2.jpg)
至此,仅处理紧凑加减法字符串的算法就完成了,请确保理解以上内容,后续的内容就基于这个框架修修改改就完事儿了。
### 三、处理乘除法
其实思路跟仅处理加减法没啥区别,拿字符串`2-3*4+5`举例,核心思路依然是把字符串分解成符号和数字的组合。
比如上述例子就可以分解为`+2``-3``*4``+5`几对儿,我们刚才不是没有处理乘除号吗,很简单,**其他部分都不用变**,在`switch`部分加上对应的 case 就行了:
```cpp
for (int i = 0; i < s.size(); i++) {
char c = s[i];
if (isdigit(c))
num = 10 * num + (c - '0');
if (!isdigit(c) || i == s.size() - 1) {
switch (sign) {
int pre;
case '+':
stk.push(num); break;
case '-':
stk.push(-num); break;
// 只要拿出前一个数字做对应运算即可
case '*':
pre = stk.top();
stk.pop();
stk.push(pre * num);
break;
case '/':
pre = stk.top();
stk.pop();
stk.push(pre / num);
break;
}
// 更新符号为当前符号,数字清零
sign = c;
num = 0;
}
}
```
![](../pictures/calculator/3.jpg)
**乘除法优先于加减法体现在,乘除法可以和栈顶的数结合,而加减法只能把自己放入栈**
现在我们思考一下**如何处理字符串中可能出现的空格字符**。其实也非常简单,想想空格字符的出现,会影响我们现有代码的哪一部分?
```cpp
// 如果 c 非数字
if (!isdigit(c) || i == s.size() - 1) {
switch (c) {...}
sign = c;
num = 0;
}
```
显然空格会进入这个 if 语句,但是我们并不想让空格的情况进入这个 if因为这里会更新`sign`并清零`nums`,空格根本就不是运算符,应该被忽略。
那么只要多加一个条件即可:
```cpp
if ((!isdigit(c) && c != ' ') || i == s.size() - 1) {
...
}
```
好了,现在我们的算法已经可以按照正确的法则计算加减乘除,并且自动忽略空格符,剩下的就是如何让算法正确识别括号了。
### 四、处理括号
处理算式中的括号看起来应该是最难的,但真没有看起来那么难。
为了规避编程语言的繁琐细节,我把前面解法的代码翻译成 Python 版本:
```python
def calculate(s: str) -> int:
def helper(s: List) -> int:
stack = []
sign = '+'
num = 0
while len(s) > 0:
c = s.pop(0)
if c.isdigit():
num = 10 * num + int(c)
if (not c.isdigit() and c != ' ') or len(s) == 0:
if sign == '+':
stack.append(num)
elif sign == '-':
stack.append(-num)
elif sign == '*':
stack[-1] = stack[-1] * num
elif sign == '/':
# python 除法向 0 取整的写法
stack[-1] = int(stack[-1] / float(num))
num = 0
sign = c
return sum(stack)
# 需要把字符串转成列表方便操作
return helper(list(s))
```
这段代码跟刚才 C++ 代码完全相同,唯一的区别是,不是从左到右遍历字符串,而是不断从左边`pop`出字符,本质还是一样的。
那么,为什么说处理括号没有看起来那么难呢,**因为括号具有递归性质**。我们拿字符串`3*(4-5/2)-6`举例:
calculate(`3*(4-5/2)-6`)
= 3 * calculate(`4-5/2`) - 6
= 3 * 2 - 6
= 0
可以脑补一下,无论多少层括号嵌套,通过 calculate 函数递归调用自己,都可以将括号中的算式化简成一个数字。**换句话说,括号包含的算式,我们直接视为一个数字就行了**。
现在的问题是,递归的开始条件和结束条件是什么?**遇到`(`开始递归,遇到`)`结束递归**
```python
def calculate(s: str) -> int:
def helper(s: List) -> int:
stack = []
sign = '+'
num = 0
while len(s) > 0:
c = s.pop(0)
if c.isdigit():
num = 10 * num + int(c)
# 遇到左括号开始递归计算 num
if c == '(':
num = helper(s)
if (not c.isdigit() and c != ' ') or len(s) == 0:
if sign == '+': ...
elif sign == '-': ...
elif sign == '*': ...
elif sign == '/': ...
num = 0
sign = c
# 遇到右括号返回递归结果
if c == ')': break
return sum(stack)
return helper(list(s))
```
![](../pictures/calculator/4.jpg)
![](../pictures/calculator/5.jpg)
![](../pictures/calculator/6.jpg)
你看,加了两三行代码,就可以处理括号了,这就是递归的魅力。至此,计算器的全部功能就实现了,通过对问题的层层拆解化整为零,再回头看,这个问题似乎也没那么复杂嘛。
### 五、最后总结
本文借实现计算器的问题,主要想表达的是一种处理复杂问题的思路。
我们首先从字符串转数字这个简单问题开始,进而处理只包含加减法的算式,进而处理包含加减乘除四则运算的算式,进而处理空格字符,进而处理包含括号的算式。
**可见,对于一些比较困难的问题,其解法并不是一蹴而就的,而是步步推进,螺旋上升的**。如果一开始给你原题,你不会做,甚至看不懂答案,都很正常,关键在于我们自己如何简化问题,如何以退为进。
**退而求其次是一种很聪明策略**。你想想啊,假设这是一道考试题,你不会实现这个计算器,但是你写了字符串转整数的算法并指出了容易溢出的陷阱,那起码可以得 20 分吧;如果你能够处理加减法,那可以得 40 分吧;如果你能处理加减乘除四则运算,那起码够 70 分了再加上处理空格字符80 有了吧。我就是不会处理括号那就算了80 已经很 OK 了好不好。
**致力于把算法讲清楚!欢迎关注我的微信公众号 labuladong查看更多通俗易懂的文章**
![labuladong](../pictures/labuladong.png)

View File

@ -0,0 +1,277 @@
# 设计Twitter
「design Twitter」是 LeetCode 上第 335 道题目不仅题目本身很有意思而且把合并多个有序链表的算法和面向对象设计OO design结合起来了很有实际意义本文就带大家来看看这道题。
至于 Twitter 的什么功能跟算法有关系,等我们描述一下题目要求就知道了。
### 一、题目及应用场景简介
Twitter 和微博功能差不多,我们主要要实现这样几个 API
```java
class Twitter {
/** user 发表一条 tweet 动态 */
public void postTweet(int userId, int tweetId) {}
/** 返回该 user 关注的人(包括他自己)最近的动态 id
最多 10 条,而且这些动态必须按从新到旧的时间线顺序排列。*/
public List<Integer> getNewsFeed(int userId) {}
/** follower 关注 followee如果 Id 不存在则新建 */
public void follow(int followerId, int followeeId) {}
/** follower 取关 followee如果 Id 不存在则什么都不做 */
public void unfollow(int followerId, int followeeId) {}
}
```
举个具体的例子,方便大家理解 API 的具体用法:
```java
Twitter twitter = new Twitter();
twitter.postTweet(1, 5);
// 用户 1 发送了一条新推文 5
twitter.getNewsFeed(1);
// return [5],因为自己是关注自己的
twitter.follow(1, 2);
// 用户 1 关注了用户 2
twitter.postTweet(2, 6);
// 用户2发送了一个新推文 (id = 6)
twitter.getNewsFeed(1);
// return [6, 5]
// 解释:用户 1 关注了自己和用户 2所以返回他们的最近推文
// 而且 6 必须在 5 之前,因为 6 是最近发送的
twitter.unfollow(1, 2);
// 用户 1 取消关注了用户 2
twitter.getNewsFeed(1);
// return [5]
```
这个场景在我们的现实生活中非常常见。拿朋友圈举例,比如我刚加到女神的微信,然后我去刷新一下我的朋友圈动态,那么女神的动态就会出现在我的动态列表,而且会和其他动态按时间排好序。只不过 Twitter 是单向关注,微信好友相当于双向关注。除非,被屏蔽...
这几个 API 中大部分都很好实现,最核心的功能难点应该是 `getNewsFeed`,因为返回的结果必须在时间上有序,但问题是用户的关注是动态变化的,怎么办?
**这里就涉及到算法了**:如果我们把每个用户各自的推文存储在链表里,每个链表节点存储文章 id 和一个时间戳 time记录发帖时间以便比较而且这个链表是按 time 有序的,那么如果某个用户关注了 k 个用户,我们就可以用合并 k 个有序链表的算法合并出有序的推文列表,正确地 `getNewsFeed` 了!
具体的算法等会讲解。不过,就算我们掌握了算法,应该如何编程表示用户 user 和推文动态 tweet 才能把算法流畅地用出来呢?**这就涉及简单的面向对象设计了**,下面我们来由浅入深,一步一步进行设计。
### 二、面向对象设计
根据刚才的分析,我们需要一个 User 类,储存 user 信息,还需要一个 Tweet 类,储存推文信息,并且要作为链表的节点。所以我们先搭建一下整体的框架:
```java
class Twitter {
private static int timestamp = 0;
private static class Tweet {}
private static class User {}
/* 还有那几个 API 方法 */
public void postTweet(int userId, int tweetId) {}
public List<Integer> getNewsFeed(int userId) {}
public void follow(int followerId, int followeeId) {}
public void unfollow(int followerId, int followeeId) {}
}
```
之所以要把 Tweet 和 User 类放到 Twitter 类里面,是因为 Tweet 类必须要用到一个全局时间戳 timestamp而 User 类又需要用到 Tweet 类记录用户发送的推文,所以它们都作为内部类。不过为了清晰和简洁,下文会把每个内部类和 API 方法单独拿出来实现。
**1、Tweet 类的实现**
根据前面的分析Tweet 类很容易实现:每个 Tweet 实例需要记录自己的 tweetId 和发表时间 time而且作为链表节点要有一个指向下一个节点的 next 指针。
```java
class Tweet {
private int id;
private int time;
private Tweet next;
// 需要传入推文内容id和发文时间
public Tweet(int id, int time) {
this.id = id;
this.time = time;
this.next = null;
}
}
```
![tweet](../pictures/设计Twitter/tweet.jpg)
**2、User 类的实现**
我们根据实际场景想一想,一个用户需要存储的信息有 userId关注列表以及该用户发过的推文列表。其中关注列表应该用集合Hash Set这种数据结构来存因为不能重复而且需要快速查找推文列表应该由链表这种数据结构储存以便于进行有序合并的操作。画个图理解一下
![User](../pictures/设计Twitter/user.jpg)
除此之外,根据面向对象的设计原则,「关注」「取关」和「发文」应该是 User 的行为,况且关注列表和推文列表也存储在 User 类中,所以我们也应该给 User 添加 followunfollow 和 post 这几个方法:
```java
// static int timestamp = 0
class User {
private int id;
public Set<Integer> followed;
// 用户发表的推文链表头结点
public Tweet head;
public User(int userId) {
followed = new HashSet<>();
this.id = userId;
this.head = null;
// 关注一下自己
follow(id);
}
public void follow(int userId) {
followed.add(userId);
}
public void unfollow(int userId) {
// 不可以取关自己
if (userId != this.id)
followed.remove(userId);
}
public void post(int tweetId) {
Tweet twt = new Tweet(tweetId, timestamp);
timestamp++;
// 将新建的推文插入链表头
// 越靠前的推文 time 值越大
twt.next = head;
head = twt;
}
}
```
**3、几个 API 方法的实现**
```java
class Twitter {
private static int timestamp = 0;
private static class Tweet {...}
private static class User {...}
// 我们需要一个映射将 userId 和 User 对象对应起来
private HashMap<Integer, User> userMap = new HashMap<>();
/** user 发表一条 tweet 动态 */
public void postTweet(int userId, int tweetId) {
// 若 userId 不存在,则新建
if (!userMap.containsKey(userId))
userMap.put(userId, new User(userId));
User u = userMap.get(userId);
u.post(tweetId);
}
/** follower 关注 followee */
public void follow(int followerId, int followeeId) {
// 若 follower 不存在,则新建
if(!userMap.containsKey(followerId)){
User u = new User(followerId);
userMap.put(followerId, u);
}
// 若 followee 不存在,则新建
if(!userMap.containsKey(followeeId)){
User u = new User(followeeId);
userMap.put(followeeId, u);
}
userMap.get(followerId).follow(followeeId);
}
/** follower 取关 followee如果 Id 不存在则什么都不做 */
public void unfollow(int followerId, int followeeId) {
if (userMap.containsKey(followerId)) {
User flwer = userMap.get(followerId);
flwer.unfollow(followeeId);
}
}
/** 返回该 user 关注的人(包括他自己)最近的动态 id
最多 10 条,而且这些动态必须按从新到旧的时间线顺序排列。*/
public List<Integer> getNewsFeed(int userId) {
// 需要理解算法,见下文
}
}
```
### 三、算法设计
实现合并 k 个有序链表的算法需要用到优先级队列Priority Queue这种数据结构是「二叉堆」最重要的应用你可以理解为它可以对插入的元素自动排序。乱序的元素插入其中就被放到了正确的位置可以按照从小到大或从大到小有序地取出元素。
```python
PriorityQueue pq
# 乱序插入
for i in {2,4,1,9,6}:
pq.add(i)
while pq not empty:
# 每次取出第一个(最小)元素
print(pq.pop())
# 输出有序1,2,4,6,9
```
借助这种牛逼的数据结构支持,我们就很容易实现这个核心功能了。注意我们把优先级队列设为按 time 属性**从大到小降序排列**,因为 time 越大意味着时间越近,应该排在前面:
```java
public List<Integer> getNewsFeed(int userId) {
List<Integer> res = new ArrayList<>();
if (!userMap.containsKey(userId)) return res;
// 关注列表的用户 Id
Set<Integer> users = userMap.get(userId).followed;
// 自动通过 time 属性从大到小排序,容量为 users 的大小
PriorityQueue<Tweet> pq =
new PriorityQueue<>(users.size(), (a, b)->(b.time - a.time));
// 先将所有链表头节点插入优先级队列
for (int id : users) {
Tweet twt = userMap.get(id).head;
if (twt == null) continue;
pq.add(twt);
}
while (!pq.isEmpty()) {
// 最多返回 10 条就够了
if (res.size() == 10) break;
// 弹出 time 值最大的(最近发表的)
Tweet twt = pq.poll();
res.add(twt.id);
// 将下一篇 Tweet 插入进行排序
if (twt.next != null)
pq.add(twt.next);
}
return res;
}
```
这个过程是这样的,下面是我制作的一个 GIF 图描述合并链表的过程。假设有三个 Tweet 链表按 time 属性降序排列,我们把他们降序合并添加到 res 中。注意图中链表节点中的数字是 time 属性,不是 id 属性:
![gif](../pictures/设计Twitter/merge.gif)
至此,这道一个极其简化的 Twitter 时间线功能就设计完毕了。
### 四、最后总结
本文运用简单的面向对象技巧和合并 k 个有序链表的算法设计了一套简化的时间线功能,这个功能其实广泛地运用在许多社交应用中。
我们先合理地设计出 User 和 Tweet 两个类,然后基于这个设计之上运用算法解决了最重要的一个功能。可见实际应用中的算法并不是孤立存在的,需要和其他知识混合运用,才能发挥实际价值。
当然,实际应用中的社交 App 数据量是巨大的,考虑到数据库的读写性能,我们的设计可能承受不住流量压力,还是有些太简化了。而且实际的应用都是一个极其庞大的工程,比如下图,是 Twitter 这样的社交网站大致的系统结构:
![design](../pictures/设计Twitter/design.png)
我们解决的问题应该只能算 Timeline Service 模块的一小部分,功能越多,系统的复杂性可能是指数级增长的。所以说合理的顶层设计十分重要,其作用是远超某一个算法的。
最后Github 上有一个优秀的开源项目,专门收集了很多大型系统设计的案例和解析,而且有中文版本,上面这个图也出自该项目。对系统设计感兴趣的读者可以点击「阅读原文」查看。
PS本文前两张图片和 GIF 是我第一次尝试用平板的绘图软件制作的,花了很多时间,尤其是 GIF 图,需要一帧一帧制作。如果本文内容对你有帮助,点个赞分个享,鼓励一下我呗!
坚持原创高质量文章,致力于把算法问题讲清楚,欢迎关注我的公众号 labuladong 获取最新文章:
![labuladong](../pictures/labuladong.jpg)

View File

@ -0,0 +1,193 @@
# 递归反转链表的一部分
反转单链表的迭代实现不是一个困难的事情,但是递归实现就有点难度了,如果再加一点难度,让你仅仅反转单链表中的一部分,你是否能**够递归实现**呢?
本文就来由浅入深step by step 地解决这个问题。如果你还不会递归地反转单链表也没关系,**本文会从递归反转整个单链表开始拓展**,只要你明白单链表的结构,相信你能够有所收获。
```java
// 单链表节点的结构
public class ListNode {
int val;
ListNode next;
ListNode(int x) { val = x; }
}
```
什么叫反转单链表的一部分呢,就是给你一个索引区间,让你把单链表中这部分元素反转,其他部分不变:
![](../pictures/%E5%8F%8D%E8%BD%AC%E9%93%BE%E8%A1%A8/title.png)
**注意这里的索引是从 1 开始的**。迭代的思路大概是:先用一个 for 循环找到第 `m` 个位置,然后再用一个 for 循环将 `m``n` 之间的元素反转。但是我们的递归解法不用一个 for 循环,纯递归实现反转。
迭代实现思路看起来虽然简单,但是细节问题很多的,反而不容易写对。相反,递归实现就很简洁优美,下面就由浅入深,先从反转整个单链表说起。
### 一、递归反转整个链表
这个算法可能很多读者都听说过,这里详细介绍一下,先直接看实现代码:
```java
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` 为起点」的链表反转,并返回反转之后的头结点**
明白了函数的定义,在来看这个问题。比如说我们想反转这个链表:
![](../pictures/%E5%8F%8D%E8%BD%AC%E9%93%BE%E8%A1%A8/1.jpg)
那么输入 `reverse(head)` 后,会在这里进行递归:
```java
ListNode last = reverse(head.next);
```
不要跳进递归(你的脑袋能压几个栈呀?),而是要根据刚才的函数定义,来弄清楚这段代码会产生什么结果:
![](../pictures/%E5%8F%8D%E8%BD%AC%E9%93%BE%E8%A1%A8/2.jpg)
这个 `reverse(head.next)` 执行完成后,整个链表就成了这样:
![](../pictures/%E5%8F%8D%E8%BD%AC%E9%93%BE%E8%A1%A8/3.jpg)
并且根据函数定义,`reverse` 函数会返回反转之后的头结点,我们用变量 `last` 接收了。
现在再来看下面的代码:
```java
head.next.next = head;
```
![](../pictures/%E5%8F%8D%E8%BD%AC%E9%93%BE%E8%A1%A8/4.jpg)
接下来:
```java
head.next = null;
return last;
```
![](../pictures/%E5%8F%8D%E8%BD%AC%E9%93%BE%E8%A1%A8/5.jpg)
神不神奇,这样整个链表就反转过来了!递归代码就是这么简洁优雅,不过其中有两个地方需要注意:
1、递归函数要有 base case也就是这句
```java
if (head.next == null) return head;
```
意思是如果链表只有一个节点的时候反转也是它自己,直接返回即可。
2、当链表递归反转之后新的头结点是 `last`,而之前的 `head` 变成了最后一个节点,别忘了链表的末尾要指向 null
```java
head.next = null;
```
理解了这两点后,我们就可以进一步深入了,接下来的问题其实都是在这个算法上的扩展。
### 二、反转链表前 N 个节点
这次我们实现一个这样的函数:
```java
// 将链表的前 n 个节点反转n <= 链表长度)
ListNode reverseN(ListNode head, int n)
```
比如说对于下图链表,执行 `reverseN(head, 3)`
![](../pictures/%E5%8F%8D%E8%BD%AC%E9%93%BE%E8%A1%A8/6.jpg)
解决思路和反转整个链表差不多,只要稍加修改即可:
```java
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` 连接上。
![](../pictures/%E5%8F%8D%E8%BD%AC%E9%93%BE%E8%A1%A8/7.jpg)
OK如果这个函数你也能看懂就离实现「反转一部分链表」不远了。
### 三、反转链表的一部分
现在解决我们最开始提出的问题,给一个索引区间 `[m,n]`(索引从 1 开始),仅仅反转区间中的链表元素。
```java
ListNode reverseBetween(ListNode head, int m, int n)
```
首先,如果 `m == 1`,就相当于反转链表开头的 `n` 个元素嘛,也就是我们刚才实现的功能:
```java
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` 呢……
区别于迭代思想,这就是递归思想,所以我们可以完成代码:
```java
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)。所以递归操作链表可以作为对递归算法的练习或者拿去和小伙伴装逼,但是考虑效率的话还是使用迭代算法更好。
坚持原创高质量文章,致力于把算法问题讲清楚,欢迎关注我的公众号 labuladong 获取最新文章:
![labuladong](../pictures/labuladong.jpg)

View File

@ -0,0 +1,203 @@
# 队列实现栈|栈实现队列
队列是一种先进先出的数据结构,栈是一种先进后出的数据结构,形象一点就是这样:
![](../pictures/%E6%A0%88%E9%98%9F%E5%88%97/1.jpg)
这两种数据结构底层其实都是数组或者链表实现的,只是 API 限定了它们的特性,那么今天就来看看如何使用「栈」的特性来实现一个「队列」,如何用「队列」实现一个「栈」。
### 一、用栈实现队列
首先,队列的 API 如下:
```java
class MyQueue {
/** 添加元素到队尾 */
public void push(int x);
/** 删除队头的元素并返回 */
public int pop();
/** 返回队头元素 */
public int peek();
/** 判断队列是否为空 */
public boolean empty();
}
```
我们使用两个栈 `s1, s2` 就能实现一个队列的功能(这样放置栈可能更容易理解):
![](../pictures/%E6%A0%88%E9%98%9F%E5%88%97/2.jpg)
```java
class MyQueue {
private Stack<Integer> s1, s2;
public MyQueue() {
s1 = new Stack<>();
s2 = new Stack<>();
}
// ...
}
```
当调用 `push` 让元素入队时,只要把元素压入 `s1` 即可,比如说 `push` 进 3 个元素分别是 1,2,3那么底层结构就是这样
![](../pictures/%E6%A0%88%E9%98%9F%E5%88%97/3.jpg)
```java
/** 添加元素到队尾 */
public void push(int x) {
s1.push(x);
}
```
那么如果这时候使用 `peek` 查看队头的元素怎么办呢?按道理队头元素应该是 1但是在 `s1` 中 1 被压在栈底,现在就要轮到 `s2` 起到一个中转的作用了:当 `s2` 为空时,可以把 `s1` 的所有元素取出再添加进 `s2`**这时候 `s2` 中元素就是先进先出顺序了**。
![](../pictures/%E6%A0%88%E9%98%9F%E5%88%97/4.jpg)
```java
/** 返回队头元素 */
public int peek() {
if (s2.isEmpty())
// 把 s1 元素压入 s2
while (!s1.isEmpty())
s2.push(s1.pop());
return s2.peek();
}
```
同理,对于 `pop` 操作,只要操作 `s2` 就可以了。
```java
/** 删除队头的元素并返回 */
public int pop() {
// 先调用 peek 保证 s2 非空
peek();
return s2.pop();
}
```
最后,如何判断队列是否为空呢?如果两个栈都为空的话,就说明队列为空:
```java
/** 判断队列是否为空 */
public boolean empty() {
return s1.isEmpty() && s2.isEmpty();
}
```
至此,就用栈结构实现了一个队列,核心思想是利用两个栈互相配合。
值得一提的是,这几个操作的时间复杂度是多少呢?有点意思的是 `peek` 操作,调用它时可能触发 `while` 循环,这样的话时间复杂度是 O(N),但是大部分情况下 `while` 循环不会被触发,时间复杂度是 O(1)。由于 `pop` 操作调用了 `peek`,它的时间复杂度和 `peek` 相同。
像这种情况,可以说它们的**最坏时间复杂度**是 O(N),因为包含 `while` 循环,**可能**需要从 `s1``s2` 搬移元素。
但是它们的**均摊时间复杂度**是 O(1),这个要这么理解:对于一个元素,最多只可能被搬运一次,也就是说 `peek` 操作平均到每个元素的时间复杂度是 O(1)。
### 二、用队列实现栈
如果说双栈实现队列比较巧妙,那么用队列实现栈就比较简单粗暴了,只需要一个队列作为底层数据结构。首先看下栈的 API
```java
class MyStack {
/** 添加元素到栈顶 */
public void push(int x);
/** 删除栈顶的元素并返回 */
public int pop();
/** 返回栈顶元素 */
public int top();
/** 判断栈是否为空 */
public boolean empty();
}
```
先说 `push` API直接将元素加入队列同时记录队尾元素因为队尾元素相当于栈顶元素如果要 `top` 查看栈顶元素的话可以直接返回:
```java
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 要从队尾取元素。
![](../pictures/%E6%A0%88%E9%98%9F%E5%88%97/5.jpg)
解决方法简单粗暴,把队列前面的都取出来再加入队尾,让之前的队尾元素排到队头,这样就可以取出了:
![](../pictures/%E6%A0%88%E9%98%9F%E5%88%97/6.jpg)
```java
/** 删除栈顶的元素并返回 */
public int pop() {
int size = q.size();
while (size > 1) {
q.offer(q.poll());
size--;
}
// 之前的队尾元素已经到了队头
return q.poll();
}
```
这样实现还有一点小问题就是,原来的队尾元素被提到队头并删除了,但是 `top_elem` 变量没有更新,我们还需要一点小修改:
```java
/** 删除栈顶的元素并返回 */
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` 就很容易实现了,只要看底层的队列是否为空即可:
```java
/** 判断栈是否为空 */
public boolean empty() {
return q.isEmpty();
}
```
很明显,用队列实现栈的话,`pop` 操作时间复杂度是 O(N),其他操作都是 O(1)​。​
个人认为,用队列实现栈是没啥亮点的问题,但是**用双栈实现队列是值得学习的**。
![](../pictures/%E6%A0%88%E9%98%9F%E5%88%97/4.jpg)
从栈 `s1` 搬运元素到 `s2` 之后,元素在 `s2` 中就变成了队列的先进先出顺序,这个特性有点类似「负负得正」,确实不太容易想到。
希望本文对你有帮助。
坚持原创高质量文章,致力于把算法问题讲清楚,欢迎关注我的公众号 labuladong 获取最新文章:
![labuladong](../pictures/labuladong.jpg)