第七单元 线性表
1. 高效去重 有序数组/链表
我们知道对于数组来说,在尾部插入、删除元素是比较高效的,时间复杂度是 O(1),但是如果在中间或者开头插入、删除元素,就会涉及数据的搬移,时间复杂度为 O(N),效率较低。
所以对于一般处理数组的算法问题,我们要尽可能只对数组尾部的元素进行操作,以避免额外的时间复杂度。
这篇文章讲讲如何对一个有序数组去重,先看下题目:
显然,由于数组已经排序,所以重复的元素一定连在一起,找出它们并不难,但如果毎找到一个重复元素就立即删除它,就是在数组中间进行删除操作,整个时间复杂度是会达到 O(N^2)。而且题目要求我们原地修改,也就是说不能用辅助数组,空间复杂度得是 O(1)。
其实,对于数组相关的算法问题,有一个通用的技巧:要尽量避免在中间删除元素,那我就先想办法把这个元素换到最后去。
这样的话,最终待删除的元素都拖在数组尾部,一个一个 pop 掉就行了,每次操作的时间复杂度也就降低到 O(1) 了。
按照这个思路呢,又可以衍生出解决类似需求的通用方式:双指针技巧。具体一点说,应该是快慢指针。
我们让慢指针slow
走左后面,快指针fast
走在前面探路,找到一个不重复的元素就告诉slow
并让slow
前进一步。
这样当fast
指针遍历完整个数组nums
后,nums[0..slow]
就是不重复元素,之后的所有元素都是重复元素。
看下算法执行的过程:
再简单扩展一下,如果给你一个有序链表,如何去重呢?其实和数组是一模一样的,唯一的区别是把数组赋值操作变成操作指针而已:
对于链表去重,算法执行的过程是这样的:
2. 数组的第K个最大元素
题目描述
在 未排序 的数组中找到第 k 个最大的元素。请注意,你需要找的是数组排序后的第 k 个最大的元素,而不是第 k 个不同的元素。
示例 1:
输入: [3,2,1,5,6,4] 和 k = 2
输出: 5
示例 2:
输入: [3,2,3,1,2,4,5,5,6] 和 k = 4
输出: 4
说明:
你可以假设 k 总是有效的,且 1 ≤ k ≤ 数组的长度。
题目解析
方法一:返回升序排序以后索引为 len - k 的元素
题目已经告诉你了:
你需要找的是数组排序后的第 k 个最大的元素,而不是第 k 个不同的元素。
因此,升序排序以后,返回索引为 len - k 这个元素即可。
这是最简单的思路,如果只答这个方法,可能面试官并不会满意,但是在我们平时的开发工作中,还是不能忽视这种思路简单的方法,我认为理由如下:
1、最简单同时也一定是最容易编码的,编码成功的几率最高,可以用这个最简单思路编码的结果和其它思路编码的结果进行比对,验证高级算法的正确性;
2、在数据规模小、对时间复杂度、空间复杂度要求不高的时候,真没必要上 “高大上” 的算法;
3、思路简单的算法考虑清楚了,有些时候能为实现高级算法铺路。这道题正是如此,“数组排序后的第 k 个最大的元素” ,语义是从右边往左边数第 k 个元素(从 1 开始),那么从左向右数是第几个呢,我们列出几个找找规律就好了。
一共 6 个元素,找第 2 大,索引是 4;
一共 6 个元素,找第 4 大,索引是 2。
因此,目标元素的索引是 len - k,即找最终排定以后位于 len - k 的那个元素;
4、低级算法往往容错性最好,即在输入不满足题目条件的时候,往往还能得到正确的答案,而高级算法对输入数据的要求就非常苛刻。
参考代码
import java.util.Arrays;
public class Solution {
public int findKthLargest(int[] nums, int k) {
int len = nums.length;
Arrays.sort(nums);
return nums[len - k];
}
}
复杂度分析
-
时间复杂度:O(NlogN)。这里 N 是数组的长度,算法的性能消耗主要在排序,JDK 默认使用快速排序,因此时间复杂度为O(NlogN)。
-
空间复杂度:O(1)。这里是原地排序,没有借助额外的辅助空间。
到这里,我们已经分析出了:
1、我们应该返回最终排定以后位于 len - k 的那个元素;
2、性能消耗主要在排序,JDK 默认使用快速排序。
学习过 “快速排序” 的朋友,一定知道一个操作叫 partition,它是 “分而治之” 思想当中 “分” 的那一步。
经过 partition 操作以后,每一次都能排定一个元素,并且这个元素左边的数都不大于它,这个元素右边的数都不小于它,并且我们还能知道排定以后的元素的索引。
于是可以应用 “减而治之”(分治思想的特例)的思想,把问题规模转化到一个更小的范围里。
于是得到方法二。
方法二:借助 partition 操作定位
方法二则是借助 partition 操作定位到最终排定以后索引为 len - k 的那个元素。
以下的描述基于 “快速排序” 算法知识的学习,如果忘记的朋友们可以翻一翻自己的《数据结构与算法》教材,复习一下,partition 过程、分治思想和 “快速排序” 算法的优化。
我们在学习 “快速排序” 的时候,接触的第 1 个操作就是 partition(切分),简单介绍如下:
partition(切分)操作,使得:
-
对于某个索引 j,nums[j] 已经排定,即 nums[j] 经过 partition(切分)操作以后会放置在它 “最终应该放置的地方”;
-
nums[left] 到 nums[j - 1] 中的所有元素都不大于 nums[j];
-
nums[j + 1] 到 nums[right] 中的所有元素都不小于 nums[j]。
partition(切分)操作总能排定一个元素,还能够知道这个元素它最终所在的位置,这样每经过一次 partition操作就能缩小搜索的范围,这样的额思想叫做 “减而治之”(是 “分而治之” 思想的特例)。
切分过程可以不借助额外的数组空间,仅通过交换数组元素实现。下面是参考代码:
参考代码
public class Solution {
public int findKthLargest(int[] nums, int k) {
int len = nums.length;
int left = 0;
int right = len - 1;
// 转换一下,第 k 大元素的索引是 len - k
int target = len - k;
while (true) {
int index = partition(nums, left, right);
if (index == target) {
return nums[index];
} else if (index < target) {
left = index + 1;
} else {
assert index > target;
right = index - 1;
}
}
}
/**
* 在 nums 数组的 [left, right] 部分执行 partition 操作,返回 nums[i] 排序以后应该在的位置
* 在遍历过程中保持循环不变量的语义
* 1、(left, k] < nums[left]
* 2、(k, i] >= nums[left]
*
* @param nums
* @param left
* @param right
* @return
*/
public int partition(int[] nums, int left, int right) {
int pivot = nums[left];
int j = left;
for (int i = left + 1; i <= right; i++) {
if (nums[i] < pivot) {
// 小于 pivot 的元素都被交换到前面
j++;
swap(nums, j, i);
}
}
// 最后这一步不要忘记了
swap(nums, j, left);
return j;
}
private void swap(int[] nums, int index1, int index2) {
if (index1 == index2) {
return;
}
int temp = nums[index1];
nums[index1] = nums[index2];
nums[index2] = temp;
}
}
复杂度分析
-
时间复杂度:O(N)。这里 N 是数组的长度。
-
空间复杂度:O(1)。这里是原地排序,没有借助额外的辅助空间。
方法三:优先队列
优先队列的写法就很多了,这里例举一下我能想到的。
假设数组有 len
个元素。
思路 1 :把 len
个元素都放入一个最小堆中,然后再 pop() 出 len - k 个元素,此时最小堆只剩下 k
个元素,堆顶元素就是数组中的第 k
个最大元素。
思路 2 :把 len
个元素都放入一个最大堆中,然后再 pop() 出 k - 1 个元素,因为前 k - 1 大的元素都被弹出了,此时最大堆的堆顶元素就是数组中的第 k
个最大元素。
思路 3 :只用 k
个容量的优先队列,而不用全部 len
个容量。
思路 4:用 k + 1
个容量的优先队列,使得上面的过程更“连贯”一些,到了 k
个以后的元素,就进来一个,出去一个,让优先队列自己去维护大小关系。
思路 5:综合考虑以上两种情况,总之都是为了节约空间复杂度。即 k
较小的时候使用最小堆,k
较大的时候使用最大堆。
根据以上思路,分别写出下面的代码:
思路 1 参考代码
//思路 1 :把 `len` 个元素都放入一个最小堆中,然后再 pop() 出 len - k 个元素,此时最小堆只剩下 `k` 个元素,堆顶元素就是数组中的第 `k` 个最大元素。
import java.util.PriorityQueue;
public class Solution {
public int findKthLargest(int[] nums, int k) {
int len = nums.length;
// 使用一个含有 len 个元素的最小堆,默认是最小堆,可以不写 lambda 表达式:(a, b) -> a - b
PriorityQueue<Integer> minHeap = new PriorityQueue<>(len, (a, b) -> a - b);
for (int i = 0; i < len; i++) {
minHeap.add(nums[i]);
}
for (int i = 0; i < len - k; i++) {
minHeap.poll();
}
return minHeap.peek();
}
}
思路 2 参考代码
//思路 2 :把 `len` 个元素都放入一个最大堆中,然后再 pop() 出 k - 1 个元素,因为前 k - 1 大的元素都被弹出了,此时最大堆的堆顶元素就是数组中的第 `k` 个最大元素。
import java.util.PriorityQueue;
public class Solution {
public int findKthLargest(int[] nums, int k) {
int len = nums.length;
// 使用一个含有 len 个元素的最大堆,lambda 表达式应写成:(a, b) -> b - a
PriorityQueue<Integer> maxHeap = new PriorityQueue<>(len, (a, b) -> b - a);
for (int i = 0; i < len; i++) {
maxHeap.add(nums[i]);
}
for (int i = 0; i < k - 1; i++) {
maxHeap.poll();
}
return maxHeap.peek();
}
}
思路 3 参考代码
//思路 3 :只用 `k` 个容量的优先队列,而不用全部 `len` 个容量。
import java.util.PriorityQueue;
public class Solution {
public int findKthLargest(int[] nums, int k) {
int len = nums.length;
// 使用一个含有 k 个元素的最小堆
PriorityQueue<Integer> minHeap = new PriorityQueue<>(k, (a, b) -> a - b);
for (int i = 0; i < k; i++) {
minHeap.add(nums[i]);
}
for (int i = k; i < len; i++) {
// 看一眼,不拿出,因为有可能没有必要替换
Integer topEle = minHeap.peek();
// 只要当前遍历的元素比堆顶元素大,堆顶弹出,遍历的元素进去
if (nums[i] > topEle) {
minHeap.poll();
minHeap.add(nums[i]);
}
}
return minHeap.peek();
}
}
思路 4 参考代码
//思路 4:用 `k + 1` 个容量的优先队列,使得上面的过程更“连贯”一些,到了 `k` 个以后的元素,就进来一个,出去一个,让优先队列自己去维护大小关系。
import java.util.PriorityQueue;
public class Solution {
public int findKthLargest(int[] nums, int k) {
int len = nums.length;
// 最小堆
PriorityQueue<Integer> priorityQueue = new PriorityQueue<>(k + 1, (a, b) -> (a - b));
for (int i = 0; i < k; i++) {
priorityQueue.add(nums[i]);
}
for (int i = k; i < len; i++) {
priorityQueue.add(nums[i]);
priorityQueue.poll();
}
return priorityQueue.peek();
}
}
思路 5 参考代码
//思路 5:综合考虑以上两种情况,总之都是为了节约空间复杂度。即 `k` 较小的时候使用最小堆,`k` 较大的时候使用最大堆。
import java.util.PriorityQueue;
public class Solution {
// 根据 k 的不同,选最大堆和最小堆,目的是让堆中的元素更小
// 思路 1:k 要是更靠近 0 的话,此时 k 是一个较大的数,用最大堆
// 例如在一个有 6 个元素的数组里找第 5 大的元素
// 思路 2:k 要是更靠近 len 的话,用最小堆
// 所以分界点就是 k = len - k
public int findKthLargest(int[] nums, int k) {
int len = nums.length;
if (k <= len - k) {
// System.out.println("使用最小堆");
// 特例:k = 1,用容量为 k 的最小堆
// 使用一个含有 k 个元素的最小堆
PriorityQueue<Integer> minHeap = new PriorityQueue<>(k, (a, b) -> a - b);
for (int i = 0; i < k; i++) {
minHeap.add(nums[i]);
}
for (int i = k; i < len; i++) {
// 看一眼,不拿出,因为有可能没有必要替换
Integer topEle = minHeap.peek();
// 只要当前遍历的元素比堆顶元素大,堆顶弹出,遍历的元素进去
if (nums[i] > topEle) {
minHeap.poll();
minHeap.add(nums[i]);
}
}
return minHeap.peek();
} else {
// System.out.println("使用最大堆");
assert k > len - k;
// 特例:k = 100,用容量为 len - k + 1 的最大堆
int capacity = len - k + 1;
PriorityQueue<Integer> maxHeap = new PriorityQueue<>(capacity, (a, b) -> b - a);
for (int i = 0; i < capacity; i++) {
maxHeap.add(nums[i]);
}
for (int i = capacity; i < len; i++) {
// 看一眼,不拿出,因为有可能没有必要替换
Integer topEle = maxHeap.peek();
// 只要当前遍历的元素比堆顶元素大,堆顶弹出,遍历的元素进去
if (nums[i] < topEle) {
maxHeap.poll();
maxHeap.add(nums[i]);
}
}
return maxHeap.peek();
}
}
}
3. 寻找重复数
题目描述
给定一个包含 n + 1 个整数的数组 nums,其数字都在 1 到 n 之间(包括 1 和 n),可知至少存在一个重复的整数。假设只有一个重复的整数,找出这个重复的数。
示例 1:
输入: [1,3,4,2,2]
输出: 2
示例 2:
输入: [3,1,3,4,2]
输出: 3
说明:
-
不能更改原数组(假设数组是只读的)。
-
只能使用额外的 O(1) 的空间。
-
时间复杂度小于 O(n^2) 。
-
数组中只有一个重复的数字,但它可能不止重复出现一次。
题目解析
题目的限制真多!!!
首先 不能改变数组 导致无法排序,也无法用 index 和元素建立关系;
只能使用 O(1) 的空间 意味着使用哈希表去计数这条路也走不通;
时间复杂度必须小于 O(n^2) 表示暴力求解也不行;
重复的元素可重复多次 这一条加上后,本来可以通过累加求和然后做差 sum(array) - sum(1,2,...,n)
的方式也变得不可行。
本来是非常简单的一道数组遍历的题目,加上了上面这四个条件后感觉有点无从下手。
我们说做题要借助算法,而不是空想,因此对于这道题也不例外,我们可以问自己这样一个问题,就是 “什么样的算法可以不使用额外的空间解决数组上面的搜索问题?”。
静静的思索一下。
你这时应该隐隐约约知道了,难道是 二分查找!
什么?二分查找法?!
二分查找法不是对有序数组才适用么?
这里澄清一个误区,二分法的使用 并不一定 需要在排序好的数组上面进行,不要让常见的例题限制了你的思路,二分法还有一个比较高级的用法叫做 按值二分。
这道题目交代的信息很少,我们只需要关注两个东西 - 数组,数组里的元素,利用二分我们需要去思考的是,我们要找符合条件的元素作为答案,那么 比答案小的元素具有什么样的特质,比答案大的元素又具有什么样的特质?,结合题目给我们的例子来看看:
( 说明:下面的 <= 符号表明 小于或者等于。)
例1:
[1,3,4,2,2] 元素个数
<= 1 的元素:1 1
<= 2 的元素:1, 2, 2 3
<= 3 的元素:1, 2, 2, 3 4
<= 4 的元素:1, 2, 2, 3, 4 5
例2:
[3,1,3,4,2]
<= 1 的元素:1 1
<= 2 的元素:1, 2 2
<= 3 的元素:1, 2, 3, 3 4
<= 4 的元素:1, 2, 3, 3, 4 5
极端一点的例子 (必须保证数组的长度是 n + 1, 并且元素都在区间[1,n] 上, 有且只有一个重复)
[3,3,3,3,4]
<= 1 的元素: 0
<= 2 的元素: 0
<= 3 的元素:3, 3, 3, 3 4
<= 4 的元素:3, 3, 3, 3, 4 5
看完上面几个例子,相信你明白了一个事实:
-
“如果选中的数 小于 我们要找的答案,那么整个数组中小于或等于该数的元素个数必然小于或等于该元素的值;
-
如果选中的数 大于或等于 我们要找的答案,那么整个数组中小于或等于该数的元素个数必然 大于 该元素的值”
而且你可以看到,我们要找的答案其实就处于一个分界点的位置,寻找边界值,这又是二分的一个应用,而且题目已经告诉我们数组里面的值只可能在 [1, n] 之间,这么一来,思路就是在 [1, n] 区间上做二分,然后按我们之前提到的逻辑去做分割。整个解法的时间复杂度是 O(nlogn),也是满足题目要求的。
上面的解法不是最优的,但是个人觉得是根据现有的知识比较容易想到的。
另外一种 O(n) 的解法借鉴快慢指针找交点的思想,算法非常的巧妙,也非常的有趣,但不太容易想到,这里把代码放上。
代码实现一
//二分查找
class Solution {
public int findDuplicate(int[] nums) {
int len = nums.length;
int start = 1;
int end = len - 1;
while (start < end) {
int mid = start + (end - start) / 2;
int counter = 0;
for (int num:nums) {
if (num <= mid) {
counter++;
}
}
if (counter > mid) {
end = mid;
} else {
start = mid + 1;
}
}
return start;
}
}
代码实现二
//快慢指针
public int findDuplicate(int[] nums) {
int fast = nums[nums[0]];
int slow = nums[0];
while (fast != slow) {
fast = nums[nums[fast]];
slow = nums[slow];
}
slow = 0;
while (fast != slow) {
fast = nums[fast];
slow = nums[slow];
}
return slow;
}
4. 缺失的第一个正数
思路:利用 原地哈希 (哈希函数为 f[num[i]-1]=f[i])解题
题目描述
给定一个未排序的整数数组,找出其中没有出现的最小的正整数。
示例 1:
输入: [1,2,0]
输出: 3
示例 2:
输入: [3,4,-1,1]
输出: 2
示例 3:
输入: [7,8,9,11,12]
输出: 1
说明:
你的算法的时间复杂度应为O(n),并且只能使用常数级别的空间。
题目解析
给一个整形数组,找出最小缺失的正整数,例如 [0,-1,2] 中最小缺失的正整数就是 1,[ 1,2 ,4 ,9 ] 中最小缺失的正整数就是 3。
这道题如果不加上 O(n) 时间和 O(1) 空间这样的限定条件,应该再简单不过,但是加上了这两个要求,一下子使问题变得棘手。
怎么思考呢?
首先这道题给定的条件很有限,输入参数就 只有数组 ,如果非要用 O(n) 时间和 O(1) 空间来做的话,表示我们除了输入数组以外,不能借助任何其他的数据结构。
数组应该是属于一类最最基础的数据结构,除去 length 之外,就只有两个属性 index 和 value,那这道题就变成了 如何利用数组的 value 和 index 之间的关系来找到最小缺失正整数 ,如果想到了这一点,就已经成功了一半。
如果继续想下去有几点是可以明确的:
-
缺失的正整数肯定在 [1, array.length + 1] 这个范围内
-
我们可以交换输入数组中的元素的位置来让 index 和 value 的关系更加明确
-
保证 index 和 value 的关系后,我们可以通过 index 来判定整数的存在性
第一点很好理解,一个数组总共有 array.length 这么多个数,全部排满,也就是 1,2,…array.length, 那么答案就是 array.length + 1,没有排满,那么在这之间肯定是有缺失元素的。
第二点是说我们可以通过交换来让 index 和 value 形成对应,我们看的是 value,但是 index 可以辅助我们寻找。
前两点明确了,第三点就是从头到尾寻找答案的过程。
代码实现
public int firstMissingPositive(int[] nums) {
if (nums == null || nums.length == 0) {
return 1;
}
for (int i = 0; i < nums.length;) {
if (nums[i] <= 0 || nums[i] > nums.length || nums[nums[i] - 1] == nums[i]) {
i++;
continue;
}
// swap
int tmp = nums[nums[i] - 1];
nums[nums[i] - 1] = nums[i];
nums[i] = tmp;
}
for (int i = 0; i < nums.length; ++i) {
if (nums[i] != i + 1) {
return i + 1;
}
}
return nums.length + 1;
}
总结
代码中 index 和 value 的对应关系是 index = value - 1,代码实现有两点需要注意。
-
第一点,交换完后,需要判断交换过来的数是否需要被放到相应的地方,例如
[2,3,1]
-
第二点,元素越界的话,以及元素 value 已经和 index 对应上了,那么就应该继续遍历,例如
[0,-1,1]
和[1,1]
。
总的来说这道题并没有涉及什么算法和数据结构的应用,有点像脑筋急转弯的感觉,想到了就做的出,想不到的话就做不出,但是它给我们解数组问题提供了一个新的方向:利用 index 和 value 的对应关系来辅助求解。
5. 二维数组查找(类似 搜索二维矩阵)
题目描述
在一个二维数组中(每个一维数组的长度相同),每一行都按照从左到右递增的顺序排序,每一列都按照从上到下递增的顺序排序。请完成一个函数,输入这样的一个二维数组和一个整数,判断数组中是否含有该整数。
题目分析
如果没有头绪的话,很显然使用 暴力解法 是完全可以解决该问题的。
即遍历二维数组中的每一个元素,时间复杂度:O(n^2)。
其实到这里我们就可以发现,使用这种暴力解法并没有充分利用题目给出的信息。这个二维数组是有特点的。
-
每一行都是递增的
-
每一列都是递增的
解法一:二分法
对于有序数组的查找问题而言,二分法是最容易想到的一个解法。
在这里,对每一行使用二分查找,时间复杂度为 O(nlogn) 。二分查找复杂度 O(logn),一共 n 行,所以是总体的时间复杂度是 O(nlogn) 。
解法二:规律法
根据二维数组由上到下,由左到右递增的规律。
从左下角开始遍历,如果当前值比 target 小则往右找,如果比 target 大则往上找,如果存在,必然可以找到目标数字。
即选取右上角或者左下角的元素 a[row] [col] 与 target 进行比较, 当target小于元素 a[row] [col] 时,那么 target 必定在元素 a 所在行的左边,让 col-- ;当 target 大于元素 a[row] [col] 时,那么 target 必定在元素 a 所在列的下边,让 row++ ;
代码如下:
public class Solution {
public boolean Find(int target, int [][] array) {
int row = 0;
int col = array[0].length - 1;
while(row <= array.length - 1 && col >= 0){
if(target == array[row][col])
return true;
else if(target > array[row][col])
row++ ;
else
col-- ;
}
return false;
}
}
解法三:二分规律法
将解法一和解法二进行结合:对每行每列都使用二分查找,此时的时间复杂度为 O(logn * logm)。
4
比如查找数字 9,首先使用用二分查找选出一行,总共有 5 行,那么( 0 + 5 ) / 2 = 2
,所以我们找出了第 2行为基准行。
接下来对这一行(即第 2 行)又使用二分查找, 找出这一行(即第 2 行)中最后一个比目标值小的值,这里是 6。
6 及其所在的行和列把这个矩形划分为 4 部分:
-
左上部分(图 7 灰色部分),包括所在行的左边部分和所在列的上边部分:这一部分是绝对不会有目标数字的。因为这部分数字肯定比 6 小,而 6 又是小于目标数字的,所以左上部分全部小于目标数字。也就是说这个区域的数字不需要再进行判断了。
-
右下部分(图 7 绿色部分),包括所在行的右边部分,但不包括所在列的下面部分, 这一部分也是绝对不会有目标数字的。因为这部分都比 6 右边的数字 11 大,而 11 又比目标数字 9 更大,所以右下部分全部都比目标数字大。也就是说这个区域的数字也不需要再进行判断了。
-
左下部分(图 7 蓝色部分),可能含有目标数字。
-
右上部分(图 7 棕色部分),可能含有目标数字。
这样,实际上筛选的区域就只剩下左下部分(图 7 蓝色部分)和 右上部分(图 7 棕色部分)这两块区域了,相比于解法二而言,使用这种解法平均情况下每一次查找,都可以把行和列的长度减少一半。
代码如下:
public class Solution {
public boolean Find(int target, int [][] array) {
// 特殊情况处理
if (array == null || array.length == 0 || array[0].length == 0) {
return false;
}
int h = array.length - 1;
int w = array[0].length - 1;
// 如果目标值小于最小值 或者 目标值大于最大值,那肯定不存在
if (array[0][0] > target || array[h][w] < target) {
return false;
}
return binarySearchIn2DArray(array, target, 0, h, 0, w);
}
public static boolean binarySearchIn2DArray(int[][] array, int target, int startX, int endX, int startY, int endY) {
if (startX > endX || startY > endY) {
return false;
}
//首先,根据二分法找出中间行
int x = (startX + endX) / 2;
//对该行进行二分查找
int result = binarySearch(array[x], target, startY, endY);
//找到的值位于 x 行,result 列
if (array[x][result] == target) {
return true; // 如果找到则成功
}
//对剩余的两部分分别进行递归查找
return binarySearchIn2DArray(array, target, startX, x - 1, result + 1, endY)
|| binarySearchIn2DArray(array, target, x + 1, endX, startY, result);
}
public static int binarySearch(int[] array, int target, int start, int end) {
int i = (start + end) / 2;
if (array[i] == target || start > end) {
return i;
} else if (array[i] > target) {
return binarySearch(array, target, start, i - 1);
} else {
return binarySearch(array, target, i + 1, end);
}
}
}
6. 单调栈
定义
小伙伴们都应该非常熟悉栈,栈的一个很鲜明的性质就是:先进后出 。
而所谓 单调栈 则是在栈的 先进后出 基础之上额外添加一个特性:从栈顶到栈底的元素是严格递增(or递减)。
具体进栈过程如下:
-
对于单调递增栈,若当前进栈元素为
e
,从栈顶开始遍历元素,把小于e
或者等于e
的元素弹出栈,直接遇到一个大于e
的元素或者栈为空为止,然后再把e
压入栈中。 -
对于单调递减栈,则每次弹出的是大于
e
或者等于e
的元素。
例子
以 单调递增栈 为例进行说明。
现在有一组数
3,4,2,6,4,5,2,3
让它们从左到右依次入栈。
具体过程如下:
7. 链表实现栈和队列
在上小节中可以了解到 链表的时间复杂度 如下:
add(index, e) 插入操作 O(n)
remove(index, e) 删除操作 O(n)
set(index, e) 修改操作 O(n)
get(index, e) 查找操作 O(n)
contains(index, e) 查找操作 O(n)
这似乎说明 链表 是一个性能不太优的数据结构,我们来对链表的接口进行一些调整,然后在看一下 时间复杂度 。
addFirst(index, e) 插入表头操作 O(1)
addLase(index, e) 插入链尾操作 O(1)
removeFirst(index, e) 删除表头操作 O(1)
removeLast(index, e) 删除链尾操作 O(1)
getFirst(index, e) 查找链表头操作 O(1)
经过添加这些接口,链表的在使用时复杂度就变成了O(1)。
链表实现栈
链表实现队列
根据队列的性质,对于队列的操作势必会影响到链表的两端,根据前文的表格可以知道一端为O(1),另外一端为O(n)。
为什么在链表中链表头的操作会简单为O(1) 呢,根据上图可以看出,因为有了一个标识位 head
,因此可以很快的定位的表头,同样的我们可以设置一个tail
变量,这样对于两端插入元素都是很容易。
这样队列从head
端删除元素,从tail
端插入元素。
head
队首负责出队,tail
队尾负责入队。
8. 栈和队列
在计算机领域离不开算法和数据结构,而在数据结构中尤为重要与基础的便是两个线性数据结构:栈与队列,本文将简单的介绍栈(Stack)和队列(Queue)的实现。
栈与队列
-
栈 (Stack)是一种后进先出(last in first off,LIFO)的数据结构
-
队列(Queue)则是一种先进先出 (fisrt in first out,FIFO)的结构
动画如下:
栈
队列
栈 (Stack)
栈是一种线性结构,与数组相比,栈对应的操作是数组的子集。
它只能从一端添加元素,也只能从一端取出元素(这一端称之为栈顶)。
Stack这种数据结构用途很广泛,在计算机的使用中,大量的运用了栈,比如编译器中的词法分析器、Java虚拟机、软件中的撤销操作(Undo)、浏览器中的回退操作,编译器中的函数调用实现等等。
栈的实现void push(E e)向栈中加入元素O(1) 均摊E pop()弹出栈顶元素O(1) 均摊E peek()查看栈顶元素O(1)int getSize()获取栈中元素个数O(1)boolean isEmpty()判断栈是否为空O(1)
说明:push和pop操作在最后面进行,有可能触发resize,但均摊来算是O(1)的。
如果你想了解更多时间复杂度的分析,欢迎关注笔者后续要更新的文章:O(n)说明的是什么问题?
栈的实现可以通过 数组 或者 链表 实现,在这里我们使用 数组来实现上述接口。
在栈的设计中,用户只关注栈顶元素存取和栈长度,因此设计代码如下:
读者可以使用 栈 这种数据结构去解决LeetCode上的第20号问题:有效的括号,也可以查看 每天一算:Valid Parentheses。
队列 Queue
队列也是一种线性数据结构,与数组相比,队列对应的操作是数组的子集。
只能从一端 (队尾) 添加元素,只能从另一端 (队首) 取出元素。
队列的应用可以在播放器上的播放列表,数据流对象,异步的数据传输结构(文件IO,管道通讯,套接字等)上体现,当然最直观的的就是排队了。
队列的实现void enqueue(E e)入队O(1) 均摊E dequeue()出队O(n)E getFront()获取队首元素O(1)int getSize()获取队列元素个数O(1)boolean isEmpty()判断队列是否为空O(1)
入队是从队尾开始,有可能触发resize,因此均摊下来是O(1)。出队是在队首,数组实现每次都要挪动所有元素,O(n)。
9.链表基本介绍
链表的定义
在《算法(第4版)》中,对链表的定义如下:
链表是一种递归的数据结构,它或者为空(null),或者是指向一个结点(node)的引用,该节点还有一个元素和一个指向另一条链表的引用。
链表的数据存储在“节点”(Node)中:
在上节的 栈与队列 一文中,都提到了 resize 这个操作,而通过观察链表的数据结构可以发现:
-
最后一个节点的 next 指向 NULL ,这个节点是最后一个节点
-
不像数组一下子必须new出来一片空间,无需考虑空间不够用或浪费
-
需要多少个数据,就能生成多少个节点挂接起来
也就是说:链表具有动态的能力,不需要去处理固定容量的问题。
正因为链表具备这种动态能力,那它也就缺失了高效的random access(随机访问)的能力。它无法与数组一样,通过一个索引(index)直接获取对应的元素。
因为在底层机制中数组开辟的空间在内存中是连续分布的,我们可以直接寻找索引对应的偏移,直接计算出数据所存储的内存地址,直接用O(1)复杂度拿出。
链表靠next连接,每个节点存储地址不同,我们只能通过next顺藤摸瓜找到我们要找的元素。
链表的实现
这些就是链表的成员变量以及常用方法。
链表的添加元素操作
对于链表这种数据结构而言,在链表头或者链尾添加元素都非常方便。
将元素插入链表的中间位置也十分简单,不过得注意插入的顺序
Node insertNode = new Node(e);
insertNode.next = prevNode.next;
prevNode.next = insertNode;
对比两组动画后,聪明的你很快就会想:能否用同样的代码来处理链表的插入头部和插入中间的操作呢?
答案是肯定的!只需要引入虚拟的头结点的概念就行了。
那么就可以得到链表的添加元素的代码:
链表的删除元素操作
对于链表的删除元素操作,需要找到目标节点的前驱节点。
prev.next = delNode.next
delNode.next = null
链表的查找元素操作
虚拟节点所在的位置索引可以视为 -1
链表的修改元素操作
基于上面 链表的查找元素操作 很容易写出 链表的修改元素操作
链表的时间复杂度
add(index, e) 插入操作 O(n)
remove(index, e) 删除操作 O(n)
set(index, e) 修改操作 O(n)
get(index, e) 查找操作 O(n)
contains(index, e) 查找操作 O(n)
正因为链表没有索引,因此链表丧失了像数组那样快速访问的能力,这也就让链表的增删改查全都是O(n)级别的。
这看上去链表像是一个性能很差的数据结构,那链表是如何能在数据结构中穿针引线呢?
请继续阅读后续的内容:如何用链表实现栈和队列
10. 用栈实现队列和用队列实现栈
用 双栈 实现队列:定义两个栈s1和s2,先将所有元素存入s1,如果s2为空,则将s1的元素取出再放入s2,这时取出s2中元素的顺序就和刚进入s1时候的顺序一样了。
用 队列 实现栈:一切照旧,但是把原来队尾的元素提到队头取出。
队列是一种先进先出的数据结构,栈是一种先进后出的数据结构,形象一点就是这样:
这两种数据结构底层其实都是数组或者链表实现的,只是 API 限定了它们的特性,那么今天就来看看如何使用「栈」的特性来实现一个「队列」,如何用「队列」实现一个「栈」。
一、用栈实现队列
首先,队列的 API 如下:
class MyQueue {
/** 添加元素到队尾 */
public void push(int x);
/** 删除队头的元素并返回 */
public int pop();
/** 返回队头元素 */
public int peek();
/** 判断队列是否为空 */
public boolean empty();
}
我们使用两个栈s1, s2
就能实现一个队列的功能(这样放置栈可能更容易理解):
class MyQueue {
private Stack<Integer> s1, s2;
public MyQueue() {
s1 = new Stack<>();
s2 = new Stack<>();
}
// ...
}
当调用push
让元素入队时,只要把元素压入s1
即可,比如说push
进 3 个元素分别是 1,2,3,那么底层结构就是这样:
/** 添加元素到队尾 */
public void push(int x) {
s1.push(x);
}
那么如果这时候使用peek
查看队头的元素怎么办呢?按道理队头元素应该是 1,但是在s1
中 1 被压在栈底,现在就要轮到s2
起到一个中转的作用了:
当s2
为空时,可以把s1
的所有元素取出再添加进s2
,这时候s2
中元素就是先进先出顺序了。
/** 返回队头元素 */
public int peek() {
if (s2.isEmpty())
// 把 s1 元素压入 s2
while (!s1.isEmpty())
s2.push(s1.pop());
return s2.peek();
}
同理,对于pop
操作,只要操作s2
就可以了。
/** 删除队头的元素并返回 */
public int pop() {
// 先调用 peek 保证 s2 非空
peek();
return s2.pop();
}
最后,如何判断队列是否为空呢?如果两个栈都为空的话,就说明队列为空:
/** 判断队列是否为空 */
public boolean empty() {
return s1.isEmpty() && s2.isEmpty();
}
至此,就用栈结构实现了一个队列,核心思想是利用两个栈互相配合。
值得一提的是,这几个操作的时间复杂度是多少呢?其他操作都是 O(1),有点意思的是peek
操作,调用它时可能触发while
循环,这样的话时间复杂度是 O(N),但是大部分情况下while
循环不会被触发,时间复杂度是 O(1)。由于pop
操作调用了peek
,它的时间复杂度和peek
相同。
像这种情况,可以说它们的最坏时间复杂度是 O(N),因为包含while
循环,可能需要从s1
往s2
搬移元素。
但是它们的均摊时间复杂度是 O(1),这个要这么理解:对于一个元素,最多只可能被搬运一次,也就是说peek
操作平均到每个元素的时间复杂度是 O(1)。
二、用队列实现栈
如果说双栈实现队列比较巧妙,那么用队列实现栈就比较简单粗暴了,只需要一个队列作为底层数据结构。首先看下栈的 API:
class MyStack {
/** 添加元素到栈顶 */
public void push(int x);
/** 删除栈顶的元素并返回 */
public int pop();
/** 返回栈顶元素 */
public int top();
/** 判断栈是否为空 */
public boolean empty();
}
先说push
API,直接将元素加入队列,同时记录队尾元素,因为队尾元素相当于栈顶元素,如果要top
查看栈顶元素的话可以直接返回:
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 要从队尾取元素。
解决方法简单粗暴,把队列前面的都取出来再加入队尾,让之前的队尾元素排到队头,这样就可以取出了:
/** 删除栈顶的元素并返回 */
public int pop() {
int size = q.size();
while (size > 1) {
q.offer(q.poll());
size--;
}
// 之前的队尾元素已经到了队头
return q.poll();
}
这样实现还有一点小问题就是,原来的队尾元素被提到队头并删除了,但是top_elem
变量没有更新,我们还需要一点小修改:
/** 删除栈顶的元素并返回 */
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();
}
最后,APIempty
就很容易实现了,只要看底层的队列是否为空即可:
/** 判断栈是否为空 */
public boolean empty() {
return q.isEmpty();
}
很明显,用队列实现栈的话,pop 操作时间复杂度是 O(N),其他操作都是 O(1)。
个人认为,用队列实现栈没啥亮点,但是用双栈实现队列是值得学习的。
出栈顺序本来就和入栈顺序相反,但是从栈s1
搬运元素到s2
之后,s2
中元素出栈的顺序就变成了队列的先进先出顺序,这个特性有点类似「负负得正」,确实不容易想到。
11. 堆栈、队列相关应用
题目一:有效的括号问题描述
给定一个只包括 '(',')','{','}','[',']' 的字符串,判断字符串是否有效。
解题思路
这道题让我们验证输入的字符串是否为括号字符串,包括大括号,中括号和小括号。
这里我们使用栈。
-
遍历输入字符串
-
如果当前字符为左半边括号时,则将其压入栈中
-
如果遇到右半边括号时,分类讨论:
-
1)如栈不为空且为对应的左半边括号,则取出栈顶元素,继续循环
-
2)若此时栈为空,则直接返回 false
-
3)若不为对应的左半边括号,反之返回 false
代码实现
class Solution {
public boolean isValid(String s) {
Stack<Character> stack = new Stack<>();
char[] chars = s.toCharArray();
for (char aChar : chars) {
if (stack.size() == 0) {
stack.push(aChar);
} else if (isSym(stack.peek(), aChar)) {
stack.pop();
} else {
stack.push(aChar);
}
}
return stack.size() == 0;
}
private boolean isSym(char c1, char c2) {
return (c1 == '(' && c2 == ')') || (c1 == '[' && c2 == ']') || (c1 == '{' && c2 == '}');
}
}
题目二:用两个栈实现队列问题描述
用两个栈来实现一个队列,完成队列的 Push 和 Pop 操作。
解题思路
in 栈用来处理入栈(push)操作,out 栈用来处理出栈(pop)操作。一个元素进入 in 栈之后,出栈的顺序被反转。当元素要出栈时,需要先进入 out 栈,此时元素出栈顺序再一次被反转,因此出栈顺序就和最开始入栈顺序是相同的,先进入的元素先退出,这就是队列的顺序。
-
push 元素时,始终是进入栈,pop 和 peek 元素时始终是走出栈。
-
pop 和 peek 操作,如果出栈为空,则需要从入栈将所有元素移到出栈,也就是调换顺序,比如开始push的顺序是 3-2-1,1 是最先进入的元素,则到出栈的顺序是 1-2-3,那 pop 操作拿到的就是 1,满足了先进先出的特点。
-
pop 和 peek 操作,如果出栈不为空,则不需要从入栈中移到数据到出栈。
Stack<Integer> in = new Stack<Integer>();
Stack<Integer> out = new Stack<Integer>();
public void push(int node) {
in.push(node);
}
public int pop() throws Exception {
if (out.isEmpty())
while (!in.isEmpty())
out.push(in.pop());
if (out.isEmpty())
throw new Exception("queue is empty");
return out.pop();
}
题目三:栈的压入、弹出序列问题描述
输入两个整数序列,第一个序列表示栈的压入顺序,请判断第二个序列是否为该栈的弹出顺序。假设压入栈的所有数字均不相等。例如序列 1,2,3,4,5 是某栈的压入顺序,序列 4,5,3,2,1是该压栈序列对应的一个弹出序列,但4,3,5,1,2就不可能是该压栈序列的弹出序列。(注意:这两个序列的长度是相等的)
解题思路
借用一个辅助的栈,遍历压栈顺序,先讲第一个放入栈中,这里是 1,然后判断栈顶元素是不是出栈顺序的第一个元素,这里是 4,很显然 1≠4 ,所以需要继续压栈,直到相等以后开始出栈。
出栈一个元素,则将出栈顺序向后移动一位,直到不相等,这样循环等压栈顺序遍历完成,如果辅助栈还不为空,说明弹出序列不是该栈的弹出顺序。
代码实现
public boolean IsPopOrder(int[] pushSequence, int[] popSequence) {
int n = pushSequence.length;
Stack<Integer> stack = new Stack<>();
for (int pushIndex = 0, popIndex = 0; pushIndex < n; pushIndex++) {
stack.push(pushSequence[pushIndex]);
while (popIndex < n && !stack.isEmpty()
&& stack.peek() == popSequence[popIndex]) {
stack.pop();
popIndex++;
}
}
return stack.isEmpty();
}
题目四:包含 min 函数的栈问题描述
定义栈的数据结构,请在该类型中实现一个能够得到栈最小元素的 min 函数。
解题思路
使用两个 stack,一个作为数据栈,另一个作为辅助栈。其中 数据栈 用于存储所有数据,而 辅助栈 用于存储最小值。
举个🌰:
-
入栈的时候:首先往空的数据栈里压入数字 3 ,此时 3 是最小值,所以把最小值压入辅助栈。接下来往数据栈里压入数字 4 。由于 4 大于之前的最小值,因此只要入数据栈,不需要压入辅助栈。
-
出栈的时候:当数据栈和辅助栈的栈顶元素相同的时候,辅助栈的栈顶元素出栈。否则,数据栈的栈顶元素出栈。
-
获得栈顶元素的时候:直接返回数据栈的栈顶元素。
-
栈最小元素:直接返回辅助栈的栈顶元素。
代码实现
private Stack<Integer> dataStack = new Stack<>();
private Stack<Integer> minStack = new Stack<>();
public void push(int node) {
dataStack.push(node);
minStack.push(minStack.isEmpty() ? node : Math.min(minStack.peek(), node));
}
public void pop() {
dataStack.pop();
minStack.pop();
}
public int top() {
return dataStack.peek();
}
public int min() {
return minStack.peek();
}
12. 堆等数据结构的应用——合并k个排序链表(看不太懂)
题目描述
合并 k 个排序链表,返回合并后的排序链表。请分析和描述算法的复杂度。
示例:
输入:
[
1->4->5,
1->3->4,
2->6
]
输出: 1->1->2->3->4->4->5->6
题目分析一
这里需要将这 k 个排序链表整合成一个排序链表,也就是说有多个输入,一个输出,类似于漏斗一样的概念。
因此,可以利用最小堆的概念。如果你对堆的概念不熟悉,可以戳这先了解一下~
取每个 Linked List 的最小节点放入一个 heap 中,排序成最小堆。然后取出堆顶最小的元素,放入输出的合并 List 中,然后将该节点在其对应的 List 中的下一个节点插入到 heap 中,循环上面步骤,以此类推直到全部节点都经过 heap。
由于 heap 的大小为始终为 k ,而每次插入的复杂度是 logk ,一共插入了 nk 个节点。时间复杂度为 O(nklogk),空间复杂度为O(k)。
代码实现
class Solution {
public ListNode mergeKLists(ListNode[] lists) {
//用heap(堆)这种数据结构,也就是 java 里面的 PriorityQueue
PriorityQueue<ListNode> pq = new PriorityQueue<>(new Comparator<ListNode>() {
public int compare(ListNode a, ListNode b) {
return a.val-b.val;
}
});
ListNode ret = null, cur = null;
for(ListNode node: lists) {
if(null != node) {
pq.add(node);
}
}
while(!pq.isEmpty()) {
ListNode node = pq.poll();
if(null == ret) {
ret = cur = node;
}
else {
cur = cur.next = node;
}
if(null != node.next) {
pq.add(node.next);
}
}
return ret;
}
}
题目分析二
这道题需要合并 k 个有序链表,并且最终合并出来的结果也必须是有序的。如果一开始没有头绪的话,可以先从简单的开始:合并 两 个有序链表。
合并两个有序链表:将两个有序链表合并为一个新的有序链表并返回。新链表是通过拼接给定的两个链表的所有节点组成的。
示例:
输入:1->2->4, 1->3->4
输出:1->1->2->3->4->4
这道题目按照题目描述做下去就行:新建一个链表,比较原始两个链表中的元素值,把较小的那个链到新链表中即可。需要注意的一点时由于两个输入链表的长度可能不同,所以最终会有一个链表先完成插入所有元素,则直接另一个未完成的链表直接链入新链表的末尾。
所以代码实现很容易写:
class Solution {
public ListNode mergeTwoLists(ListNode l1, ListNode l2) {
//新建链表
ListNode dummyHead = new ListNode(0);
ListNode cur = dummyHead;
while (l1 != null && l2 != null) {
if (l1.val < l2.val) {
cur.next = l1;
cur = cur.next;
l1 = l1.next;
} else {
cur.next = l2;
cur = cur.next;
l2 = l2.next;
}
}
// 注意点:当有链表为空时,直接连接另一条链表
if (l1 == null) {
cur.next = l2;
} else {
cur.next = l1;
}
return dummyHead.next;
}
现在回到一开始的题目:合并 K 个排序链表。
合并 K 个排序链表 与 合并两个有序链表 的区别点在于操作有序链表的数量上,因此完全可以按照上面的代码思路来实现合并 K 个排序链表。
这里可以参考 归并排序 的分治思想,将这 K 个链表先划分为两个 K/2 个链表,处理它们的合并,然后不停的往下划分,直到划分成只有一个或两个链表的任务,开始合并。
归并-分治代码实现
实现代码非常简单也容易理解,先划分,直到不能划分下去,然后开始合并。
class Solution {
public ListNode mergeKLists(ListNode[] lists){
if(lists.length == 0)
return null;
if(lists.length == 1)
return lists[0];
if(lists.length == 2){
return mergeTwoLists(lists[0],lists[1]);
}
int mid = lists.length/2;
ListNode[] l1 = new ListNode[mid];
for(int i = 0; i < mid; i++){
l1[i] = lists[i];
}
ListNode[] l2 = new ListNode[lists.length-mid];
for(int i = mid,j=0; i < lists.length; i++,j++){
l2[j] = lists[i];
}
return mergeTwoLists(mergeKLists(l1),mergeKLists(l2));
}
public ListNode mergeTwoLists(ListNode l1, ListNode l2) {
if (l1 == null) return l2;
if (l2 == null) return l1;
ListNode head = null;
if (l1.val <= l2.val){
head = l1;
head.next = mergeTwoLists(l1.next, l2);
} else {
head = l2;
head.next = mergeTwoLists(l1, l2.next);
}
return head;
}
}
题目解析方法一:贪心算法、优先队列
思路分析:
1、由于是 𝑘 个排序链表,那么这 𝑘 个排序的链表头结点中 val
最小的结点就是合并以后的链表中最小的结点;
2、最小结点所在的链表的头结点就要更新了,更新成最小结点的下一个结点(如果有的话),此时还是这 𝑘 个链表,这 𝑘k 个排序的链表头结点中 val
最小的结点就是合并以后的链表中第 2 小的结点。
写到这里,我想你应该差不多明白了,我们每一次都从这 𝑘 个排序的链表头结点中拿出 val
最小的结点“穿针引线”成新的链表,这个链表就是题目要求的“合并后的排序链表”。
“局部最优,全局就最优”,这不就是贪心算法的思想吗。
这里我们举生活中的例子来理解这个思路。
假设你是一名体育老师,有 3 个班的学生,他们已经按照身高从矮到高排好成了 3 列纵队,现在要把这 3 个班的学生也按照身高从矮到高排列 1 列纵队。我们可以这么做:
1、让 3 个班的学生按列站在你的面前,这时你能看到站在队首的学生的全身;
2、每一次队首的 3 名同学,请最矮的同学出列到“队伍 4”(即我们最终认为排好序的队列),出列的这一列的后面的所有同学都向前走一步(其实走不走都行,只要你能比较出站在你面前的 3 位在队首的同学同学的高矮即可);
3、重复第 2 步,直到 3 个班的同学全部出列完毕。
具体实现的时候,“每一次队首的 3 名同学,请最矮的同学出列”这件事情可以交给优先队列(最小堆、最小索引堆均可)去完成。在连续的两次出队之间完成“穿针引线”的工作。
LeetCode 第 23 题:合并K个排序链表-3代码实现
class Solution {
public ListNode mergeKLists(ListNode[] lists) {
int len = lists.length;
if (len == 0) {
return null;
}
PriorityQueue<ListNode> priorityQueue = new PriorityQueue<>(len, Comparator.comparingInt(a -> a.val));
ListNode dummyNode = new ListNode(-1);
ListNode curNode = dummyNode;
for (ListNode list : lists) {
if (list != null) {
// 这一步很关键,不能也没有必要将空对象添加到优先队列中
priorityQueue.add(list);
}
}
while (!priorityQueue.isEmpty()) {
// 优先队列非空才能出队
ListNode node = priorityQueue.poll();
// 当前节点的 next 指针指向出队元素
curNode.next = node;
// 当前指针向前移动一个元素,指向了刚刚出队的那个元素
curNode = curNode.next;
if (curNode.next != null) {
// 只有非空节点才能加入到优先队列中
priorityQueue.add(curNode.next);
}
}
return dummyNode.next;
}
}
复杂度分析
-
时间复杂度:O(Nlogk),这里 N 是这 𝑘 个链表的结点总数,每一次从一个优先队列中选出一个最小结点的时间复杂度是 O(logk),故时间复杂度为O(Nlogk)。
-
空间复杂度:O(k),使用优先队列需要 k 个空间,“穿针引线”需要常数个空间,因此空间复杂度为 O(k)。
方法二:分治法
如果我们不想“穿针引线”,那么“递归”、“分治”是一个不错的选择。
代码结构和“归并排序”可以说是同出一辙:
1、先一分为二,分别“递归地”解决了与原问题同结构,但规模更小的两个子问题;
2、再考虑如何合并,这个合并的过程也是一个递归方法。
代码实现
class Solution {
public ListNode mergeKLists(ListNode[] lists) {
int len = lists.length;
if (len == 0) {
return null;
}
return mergeKLists(lists, 0, len - 1);
}
public ListNode mergeKLists(ListNode[] lists, int l, int r) {
// 思考这里为什么取等于?这是因为根据下文对 mergeKLists 的递归调用情况,区间最窄的时候,只可能是左右端点重合
if (l == r) {
return lists[l];
}
int mid = (r - l) / 2 + l;
ListNode l1 = mergeKLists(lists, l, mid);
ListNode l2 = mergeKLists(lists, mid + 1, r);
return mergeTwoSortedListNode(l1, l2);
}
private ListNode mergeTwoSortedListNode(ListNode l1, ListNode l2) {
if (l1 == null) {
return l2;
}
if (l2 == null) {
return l1;
}
if (l1.val < l2.val) {
l1.next = mergeTwoSortedListNode(l1.next, l2);
return l1;
}
l2.next = mergeTwoSortedListNode(l1, l2.next);
return l2;
}
}
复杂度分析
-
时间复杂度:O(Nlogk),这里 N 是这 k 个链表的结点总数,k 个链表二分是对数级别的。
-
空间复杂度:O(1),合并两个排序链表需要“穿针引线”的指针数是常数个的。
13. 手写链表
思维导图
1 熟悉结构
首先我们要知道链表的结构以及每个节点的结构,这是我们手写链表的第一步,也是学习链表的第一步。我们知道,每个链表时这样表示的:
那每个节点结构是由数据域和指针域组成,数据域是存放数据的,而指针域存放下一结点的地址。
我们可以通过数据域访问到我们要的数据,而通过指针域访问到当前结点以后的结点,那么将这些结点串起来,就是一个链表。
那么用代码怎么来表示呢?
我们通常会用到函数,我们如果将一个函数抽象成一个结点,那么我们给函数添加两个属性,一个属性是存放数据的属性data
,另一个属性是存放指向下一个结点的指针属性next
。
你可能会问,如果我们有成千上万个结点,要定义成千上万个函数?有一个函数叫做构造函数,想必大家都听说过,声明一个构造函数就可以创造出成千上万个结点实例。
1function Node(data){
2 this.data = data;
3 this.next = null;
4}
还有一个方法就是使用类的概念,在JavaScript
中,类的概念在ES6
才出现,使用 Class
来声明一个类,我们可以为类添加两个属性,同上,一样可以创造出多个结点实例。
1class Node{
2 constructor(data){
3 this.data = data;
4 this.next = null;
5 }
6}
2 理清思路
既然链表的结构弄明白了,那么我们开始理思路,我们就先拿最简单的单链表开刀,我们要完成两个操作,插入数据和删除数据。
如果我想插入数据,你可能会问,往哪里插呢?有几种插入的方法?
开始想,插入到单链表的头部算一种。
然后插入到单链表的中间算一种。
插入到单链表尾部又算一种。
所有可能的情况就三种。那么删除呢?想必你也想到了,也一共三种,删除头部、删除中间部分、删除尾部。
如果你觉的现在可以写代码了,那你就错了,虽然我们的思路非常清晰,但是面试官仅仅考我们思路吗?其实这一关你只打败了百分之50%
的人,最重点、最主要的是在下一个部分,边界条件。
3 边界条件
边界条件是这五个步骤最容易犯错的一部分,因为通常考虑的不全面,导致了最后的面试未通过。如果想做好这一部分,也不难,跟着小鹿的方法走。
3.1 输入边界
首先我们先考虑用户输入的参数,比如传入一个链表,我们首先要判断链表是否为空,如果为空我们就不能让它执行下边的程序。再比如插入一个结点到指定结点的后边,那么你也要判断输入的结点是否为空,而且还要判断该结点是否存在该链表中。对于这些输入值的判断,小鹿给他同一起个名字叫做输入边界。
3.2 特殊边界
特殊边界考虑到一些特殊情况,比如插入数据,我们插入数据一般考虑到插入尾部,但是突然面试官插入到头部,插入尾部的代码并不适用于插入到头部,所以呢需要考虑这种情况,删除节点也是同样思考。其实特殊边界最主要考虑到一些逻辑上的特殊情况,考察应聘者的考虑的是否全面。小鹿给他起个名字叫做特殊边界。
4 手写代码
4.1 定义结点
1class Node{
2 constructor(data){
3 this.data = data;
4 this.next = null;
5 }
6}
4.2 增加结点
咱们就以单链表中部添加数据为例子,分解成每个步骤,每个步骤对应代码如下:
1、保存临时地址(4
结点的地址),需要进行遍历查找到3
结点,也就是下列代码的currentNode
结点。
1//先查找该元素
2let currentNode = this.findByValue(element);
3// 保存 3 结点的下一结点地址(4 结点的地址)
4let pre = currentNode.next
2、创建新结点,将新结点(5
结点)的指针指向下一结点指针(4
结点地址,已经在上一步骤保存下来了)
1let newNode = new Node(value);
2newNode.next = pre;
3、将3
的结点地址指向新结点(5
结点)
1 currentNode.next = newNode;
以上步骤分析完毕,剩下的两个在头部插入和在尾部插入同样的分析方式,将这两个作为练习题,课下自己试一试这个步骤。
4.3 删除节点
删除节点也分为三种,头部、中部、尾部,咱们就删除中间结点为例进行删除,我们详细看步骤操作。
我们先看删除的全部动画,然后再分步拆分。
断开3
结点的指针(断开3
结点相当于让2
结点直接指向4
结点)
1 let currentNode = this.head;
2 // 用来记录 3 结点的前一结点
3 let preNode = null;
4 // 遍历查找 3 结点
5 while(currentNode !== null && currentNode.data !== value){
6 // 3 结点的前一结点
7 preNode = currentNode;
8 // 3 结点
9 currentNode = currentNode.next;
10}
让结点2
的指针指向4
结点,完成删除。
1preNode.next = currentNode.next;
剩下的删除头结点和删除尾结点同样的步骤,自己动手尝试下。
所有代码实现:
1/**
2 * 2019/3/23
3 * 公众号:「一个不甘平凡的码农」
4 * @author 小鹿
5 * 功能:单链表的插入、删除、查找
6 * 【插入】:插入到指定元素后方
7 * 1、查找该元素是否存在?
8 * 2、没有找到返回 -1
9 * 3、找到进行创建结点并插入链表。
10 *
11 * 【查找】:按值查找/按索引查找
12 * 1、判断当前结点是否等于null,且是否等于给定值?
13 * 2、判断是否可以找到该值?
14 * 3、没有找到返回 -1;
15 * 4、找到该值返回结点;
16 *
17 * 【删除】:按值删除
18 * 1、判断是否找到该值?
19 * 2、找到记录前结点,进行删除;
20 * 3、找不到直接返回-1;
21 */
22//定义结点
23class Node{
24 constructor(data){
25 this.data = data;
26 this.next = null;
27 }
28}
29
30//定义链表
31class LinkList{
32 constructor(){
33 //初始化头结点
34 this.head = new Node('head');
35 }
36
37 //根据 value 查找结点
38 findByValue = (value) =>{
39 let currentNode = this.head;
40 while(currentNode !== null && currentNode.data !== value){
41 currentNode = currentNode.next;
42 }
43 //判断该结点是否找到
44 console.log(currentNode)
45 return currentNode === null ? -1 : currentNode;
46 }
47
48 //根据 index 查找结点
49 findByIndex = (index) =>{
50 let pos = 0;
51 let currentNode = this.head;
52 while(currentNode !== null && pos !== index){
53 currentNode = currentNode.next;
54 pos++;
55 }
56 //判断是否找到该索引
57 console.log(currentNode)
58 return currentNode === null ? -1 : currentNode;
59 }
60
61 //插入元素(指定元素向后插入)
62 insert = (value,element) =>{
63 //先查找该元素
64 let currentNode = this.findByValue(element);
65 //如果没有找到
66 if(currentNode == -1){
67 console.log("未找到插入位置!")
68 return;
69 }
70 let newNode = new Node(value);
71 newNode.next = currentNode.next;
72 currentNode.next = newNode;
73 }
74
75 //根据值删除结点
76 delete = (value) =>{
77 let currentNode = this.head;
78 let preNode = null;
79 while(currentNode !== null && currentNode.data !== value){
80 preNode = currentNode;
81 currentNode = currentNode.next;
82 }
83 if(currentNode == null) return -1;
84 preNode.next = currentNode.next;
85 }
86
87 //遍历所有结点
88 print = () =>{
89 let currentNode = this.head
90 //如果结点不为空
91 while(currentNode !== null){
92 console.log(currentNode.data)
93 currentNode = currentNode.next;
94 }
95 }
96}
97
98//测试
99const list = new LinkList()
100list.insert('xiao','head');
101list.insert('lu','xiao');
102list.insert('ni','head');
103list.insert('hellow','head');
104list.print()
105console.log('-------------删除元素------------')
106list.delete('ni')
107list.delete('xiao')
108list.print()
109console.log('-------------按值查找------------')
110list.findByValue('lu')
111console.log('-------------按索引查找------------')
112list.print()
5 测试用例
5.1 普通测试
普通测试就是输入一个正常的值,比如单链表中插入数据
5.2 特殊测试
特殊输入可以参照上边边界条件中的特殊边界进行测试,比如在头部插入数据,在尾部插入数据等特殊情况的测试。
5.3 输入测试
对于输入测试的内容参考上边边界条件中的输入边界,比如:输入一个空链表测试一下程序能否正常的运行。
6 小结
做一个小结。今天的手写链表主要从五部分下手,从前到后依次为熟悉结构、理清思路、手写代码、测试用例。以后无论手写什么代码,有五步走,对于面试完全没有问题啦。
14. 链表常见应用
【拓展补充:哈希表查找的时间复杂度为O(1)的原因:
key->下标->找到该元素
Hash 表的物理存储其实是一个数组,如果我们能够根据 Key 计算出数组下标,那么就可以快速在数组中查找到需要的 Key 和 Value。许多编程语言支持获得任意对象的 HashCode,比如 Java 语言中 HashCode 方法包含在根对象 Object 中,其返回值是一个 Int。我们可以利用这个 Int 类型的 HashCode 计算数组下标。最简单的方法就是余数法,使用 Hash 表的数组长度对 HashCode 求余, 余数即为 Hash 表数组的下标,使用这个下标就可以直接访问得到 Hash 表中存储的 Key、Value。】
输出单链表第k个节点
2.1 问题描述
题目:输入一个单链表,输出此链表中的倒数第 K 个节点。(去除头结点,节点计数从 1 开始)。
2.2 两次遍历法
2.2.1 解题思想
(1)遍历单链表,遍历同时得出链表长度 N 。
(2)再次从头遍历,访问至第 N - K 个节点为所求节点。
/*计算链表长度*/
int listLength(ListNode* pHead){
int count = 0;
ListNode* pCur = pHead->next;
if(pCur == NULL){
printf("error");
}
while(pCur){
count++;
pCur = pCur->pNext;
}
return count;
}
/*查找第k个节点的值*/
ListNode* searchNodeK(ListNode* pHead, int k){
int i = 0;
ListNode* pCur = pHead;
//计算链表长度
int len = listLength(pHead);
if(k > len){
printf("error");
}
//循环len-k+1次
for(i=0; i < len-k+1; i++){
pCur = pCur->next;
}
return pCur;//返回倒数第K个节点
}
采用这种遍历方式需要两次遍历链表,时间复杂度为O(n※2)。可见这种方式最为简单,也较好理解,但是效率低下。
2.3 递归法
2.3.1 解题思想
(1)定义num = k
(2)使用递归方式遍历至链表末尾。
(3)由末尾开始返回,每返回一次 num 减 1
(4)当 num 为 0 时,即可找到目标节点
int num;//定义num值
ListNode* findKthTail(ListNode* pHead, int k) {
num = k;
if(pHead == NULL)
return NULL;
//递归调用
ListNode* pCur = findKthTail(pHead->next, k);
if(pCur != NULL)
return pCur;
else{
num--;// 递归返回一次,num值减1
if(num == 0)
return pHead;//返回倒数第K个节点
return NULL;
}
}
使用递归的方式实现仍然需要两次遍历链表,时间复杂度为O(n※2)。
2.4 双指针法
2.4.1 解题思想
(1)定义两个指针 p1 和 p2 分别指向链表头节点。
(2)p1 前进 K 个节点,则 p1 与 p2 相距 K 个节点。
(3)p1,p2 同时前进,每次前进 1 个节点。
(4)当 p1 指向到达链表末尾,由于 p1 与 p2 相距 K 个节点,则 p2 指向目标节点。
ListNode* findKthTail(ListNode *pHead, int K){
if (NULL == pHead || K == 0)
return NULL;
//p1,p2均指向头节点
ListNode *p1 = pHead;
ListNode *p2 = pHead;
//p1先出发,前进K个节点
for (int i = 0; i < K; i++) {
if (p1)//防止k大于链表节点的个数
p1 = p1->_next;
else
return NULL;
}
while (p1)//如果p1没有到达链表结尾,则p1,p2继续遍历
{
p1 = p1->_next;
p2 = p2->_next;
}
return p2;//当p1到达末尾时,p2正好指向倒数第K个节点
}
可以看出使用双指针法只需遍历链表一次,这种方法更为高效时间复杂度为O(n),通常笔试题目中要考的也是这种方法。
链表中存在环问题
3.1 判断链表是否有环
单链表中的环是指链表末尾的节点的 next 指针不为 NULL ,而是指向了链表中的某个节点,导致链表中出现了环形结构。
链表中有环示意图:
链表的末尾节点 8 指向了链表中的节点 3,导致链表中出现了环形结构。
对于链表是否是由有环的判断方法有哪些呢?
3.1.1 穷举比较法
3.1.1.1 解题思想
(1)遍历链表,记录已访问的节点。
(2)将当前节点与之前以及访问过的节点比较,若有相同节点则有环。
否则,不存在环。
这种穷举比较思想简单,但是效率过于低下,尤其是当链表节点数目较多,在进行比较时花费大量时间,时间复杂度大致在 O(n^2)。这种方法自然不是出题人的理想答案。如果笔试面试中使用这种方法,估计就要跪了,忘了这种方法吧。
3.1.2 哈希缓存法
既然在穷举遍历时,元素比较过程花费大量时间,那么有什么办法可以提高比较速度呢?
3.1.2.1 解题思想
(1)首先创建一个以节点 ID 为键的 HashSe t集合,用来存储曾经遍历过的节点。
(2)从头节点开始,依次遍历单链表的每一个节点。
(3)每遍历到一个新节点,就用新节点和 HashSet 集合当中存储的节点作比较,如果发现 HashSet 当中存在相同节点 ID,则说明链表有环,如果 HashSet 当中不存在相同的节点 ID,就把这个新节点 ID 存入 HashSet ,之后进入下一节点,继续重复刚才的操作。
假设从链表头节点到入环点的距离是 a ,链表的环长是 r 。而每一次 HashSet 查找元素的时间复杂度是 O(1), 所以总体的时间复杂度是 1 * ( a + r ) = a + r
,可以简单理解为 O(n) 。而算法的空间复杂度还是 a + r - 1,可以简单地理解成 O(n) 。
3.1.3 快慢指针法
3.1.3.1 解题思想
(1)定义两个指针分别为 slow,fast,并且将指针均指向链表头节点。
(2)规定,slow 指针每次前进 1 个节点,fast 指针每次前进两个节点。
(3)当 slow 与 fast 相等,且二者均不为空,则链表存在环。
若链表中存在环,则快慢指针必然能在环中相遇。这就好比在环形跑道中进行龟兔赛跑。由于兔子速度大于乌龟速度,则必然会出现兔子与乌龟再次相遇情况。因此,当出现快慢指针相等时,且二者不为NULL,则表明链表存在环。
3.1.3.3 代码实现
bool isExistLoop(ListNode* pHead) {
ListNode* fast;//慢指针,每次前进一个节点
ListNode* slow;//快指针,每次前进2个节点
slow = fast = pHead ; //两个指针均指向链表头节点
//当没有到达链表结尾,则继续前进
while (slow != NULL && fast -> next != NULL) {
slow = slow -> next ; //慢指针前进一个节点
fast = fast -> next -> next ; //快指针前进两个节点
if (slow == fast) //若两个指针相遇,且均不为NULL则存在环
return true ;
}
//到达末尾仍然没有相遇,则不存在环
return false ;
}
3.2 定位环入口
在 3.1 节中,已经实现了链表中是否有环的判断方法。那么,当链表中存在环,如何确定环的入口节点呢?
3.2.1 解题思想
slow 指针每次前进一个节点,故 slow 与 fast 相遇时,slow 还没有遍历完整个链表。设 slow 走过节点数为 s,fast 走过节点数为 2s。设环入口点距离头节点为 a,slow 与 fast 首次相遇点距离入口点为 b,环的长度为 r。
则有:
s = a + b;
2s = n * r + a + b; n 代表fast指针已经在环中循环的圈数。
则推出:
s = n * r; 意味着slow指针走过的长度为环的长度整数倍。
若链表头节点到环的末尾节点度为 L,slow 与 fast 的相遇节点距离环入口节点为 X。
则有:
a+X = s = n * r = (n - 1) * r + (L - a);
a = (n - 1) * r + (L - a - X);
上述等式可以看出:
从 slow 与 fast 相遇点出发一个指针 p1,请进 (L - a - X) 步,则此指针到达入口节点。同时指针 p2 从头结点出发,前进 a 步。当 p1 与 p2 相遇时,此时 p1 与 p2 均指向入口节点。
例如图3.1所示链表:
slow 走过节点 s = 6;
fast 走过节点 2s = 12;
环入口节点据流头节点 a = 3;
相遇点距离头节点 X = 3;
L = 8;
r = 6;
可以得出 a = (n - 1) * r + (L - a - X)结果成立。
//找到环中的相遇节点
ListNode* getMeetingNode(ListNode* pHead) // 假设为带头节点的单链表
{
ListNode* fast;//慢指针,每次前进一个节点
ListNode* slow;//快指针,每次前进2个节点
slow = fast = pHead ; //两个指针均指向链表头节点
//当没有到达链表结尾,则继续前进
while (slow != NULL && fast -> next != NULL){
slow = slow -> next ; //慢指针前进一个节点
fast = fast -> next -> next ; //快指针前进两个节点
if (slow == fast) //若两个指针相遇,且均不为NULL则存在环
return slow;
}
//到达末尾仍然没有相遇,则不存在环
return NULL ;
}
//找出环的入口节点
ListNode* getEntryNodeOfLoop(ListNode* pHead){
ListNode* meetingNode = getMeetingNode(pHead); // 先找出环中的相遇节点
if (meetingNode == NULL)
return NULL;
ListNode* p1 = meetingNode;
ListNode* p2 = pHead;
while (p1 != p2) // p1和p2以相同的速度向前移动,当p2指向环的入口节点时,p1已经围绕着环走了n圈又回到了入口节点。
{
p1 = p1->next;
p2 = p2->next;
}
//返回入口节点
return p1;
}
3.3 计算环长度
3.3.1 解题思想
在3.1中找到了 slow 与 fast 的相遇节点,令 solw 与 fast 指针从相遇节点出发,按照之前的前进规则,当 slow 与fast 再次相遇时,slow 走过的长度正好为环的长度。
int getLoopLength(ListNode* head){
ListNode* slow = head;
ListNode* fast = head;
while ( fast && fast->next ){
slow = slow->next;
fast = fast->next->next;
if ( slow == fast )//第一次相遇
break;
}
//slow与fast继续前进
slow = slow->next;
fast = fast->next->next;
int length = 1; //环长度
while ( fast != slow )//再次相遇
{
slow = slow->next;
fast = fast->next->next;
length ++; //累加
}
//当slow与fast再次相遇,得到环长度
return length;
}
使用链表实现大数加法
4.1 问题描述
两个用链表代表的整数,其中每个节点包含一个数字。数字存储按照在原来整数中相反的顺序,使得第一个数字位于链表的开头。写出一个函数将两个整数相加,用链表形式返回和。
例如:
输入:
3->1->5->null
5->9->2->null,
输出:
8->0->8->null
4.2 代码实现
ListNode* numberAddAsList(ListNode* l1, ListNode* l2) {
ListNode *ret = l1, *pre = l1;
int up = 0;
while (l1 != NULL && l2 != NULL) {
//数值相加
l1->val = l1->val + l2->val + up;
//计算是否有进位
up = l1->val / 10;
//保留计算结果的个位
l1->val %= 10;
//记录当前节点位置
pre = l1;
//同时向后移位
l1 = l1->next;
l2 = l2->next;
}
//若l1到达末尾,说明l1长度小于l2
if (l1 == NULL)
//pre->next指向l2的当前位置
pre->next = l2;
//l1指针指向l2节点当前位置
l1 = pre->next;
//继续计算剩余节点
while (l1 != NULL) {
l1->val = l1->val + up;
up = l1->val / 10;
l1->val %= 10;
pre = l1;
l1 = l1->next;
}
//最高位计算有进位,则新建一个节点保留最高位
if (up != 0) {
ListNode *tmp = new ListNode(up);
pre->next = tmp;
}
//返回计算结果链表
return ret;
}
有序链表合并
5.1 问题描述
题目:将两个有序链表合并为一个新的有序链表并返回。新链表是通过拼接给定的两个链表的所有节点组成的。
示例:
输入:
1->2->4,
1->3->4
输出:
1->1->2->3->4->4
5.2 一般方案
5.2.1 解题思想
(1)对空链表存在的情况进行处理,假如 pHead1 为空则返回 pHead2 ,pHead2 为空则返回 pHead1。(两个都为空此情况在pHead1为空已经被拦截)
(2)在两个链表无空链表的情况下确定第一个结点,比较链表1和链表2的第一个结点的值,将值小的结点保存下来为合并后的第一个结点。并且把第一个结点为最小的链表向后移动一个元素。
(3)继续在剩下的元素中选择小的值,连接到第一个结点后面,并不断next将值小的结点连接到第一个结点后面,直到某一个链表为空。
(4)当两个链表长度不一致时,也就是比较完成后其中一个链表为空,此时需要把另外一个链表剩下的元素都连接到第一个结点的后面。
5.2.2 代码实现
ListNode* mergeTwoOrderedLists(ListNode* pHead1, ListNode* pHead2){
ListNode* pTail = NULL;//指向新链表的最后一个结点 pTail->next去连接
ListNode* newHead = NULL;//指向合并后链表第一个结点
if (NULL == pHead1){
return pHead2;
}else if(NULL == pHead2){
return pHead1;
}else{
//确定头指针
if ( pHead1->data < pHead2->data){
newHead = pHead1;
pHead1 = pHead1->next;//指向链表的第二个结点
}else{
newHead = pHead2;
pHead2 = pHead2->next;
}
pTail = newHead;//指向第一个结点
while ( pHead1 && pHead2) {
if ( pHead1->data <= pHead2->data ){
pTail->next = pHead1;
pHead1 = pHead1->next;
}else {
pTail->next = pHead2;
pHead2 = pHead2->next;
}
pTail = pTail->next;
}
if(NULL == pHead1){
pTail->next = pHead2;
}else if(NULL == pHead2){
pTail->next = pHead1;
}
return newHead;
}
5.3 递归方案
5.3.1 解题思想
(1)对空链表存在的情况进行处理,假如 pHead1 为空则返回 pHead2 ,pHead2 为空则返回 pHead1。
(2)比较两个链表第一个结点的大小,确定头结点的位置
(3)头结点确定后,继续在剩下的结点中选出下一个结点去链接到第二步选出的结点后面,然后在继续重复(2 )(3) 步,直到有链表为空。
5.3.2 代码实现
ListNode* mergeTwoOrderedLists(ListNode* pHead1, ListNode* pHead2){
ListNode* newHead = NULL;
if (NULL == pHead1){
return pHead2;
}else if(NULL ==pHead2){
return pHead2;
}else{
if (pHead1->data < pHead2->data){
newHead = pHead1;
newHead->next = mergeTwoOrderedLists(pHead1->next, pHead2);
}else{
newHead = pHead2;
newHead->next = mergeTwoOrderedLists(pHead1, pHead2->next);
}
return newHead;
}
}
删除链表中节点,要求时间复杂度为O(1)
6.1 问题描述
给定一个单链表中的表头和一个等待被删除的节点。请在 O(1) 时间复杂度删除该链表节点。并在删除该节点后,返回表头。
示例:
给定 1->2->3->4,和节点 3,返回 1->2->4。
6.2 解题思想
在之前介绍的单链表删除节点中,最普通的方法就是遍历链表,复杂度为O(n)。
如果我们把删除节点的下一个结点的值赋值给要删除的结点,然后删除这个结点,这相当于删除了需要删除的那个结点。因为我们很容易获取到删除节点的下一个节点,所以复杂度只需要O(1)。
示例
单链表:1->2->3->4->NULL
若要删除节点 3 。第一步将节点3的下一个节点的值4赋值给当前节点。变成 1->2->4->4->NULL,然后将就 4 这个结点删除,就达到目的了。 1->2->4->NULL
如果删除的节点的是头节点,把头结点指向 NULL。
如果删除的节点的是尾节点,那只能从头遍历到头节点的上一个结点。
6.3 图解过程
void deleteNode(ListNode **pHead, ListNode* pDelNode) {
if(pDelNode == NULL)
return;
if(pDelNode->next != NULL){
ListNode *pNext = pDelNode->next;
//下一个节点值赋给待删除节点
pDelNode->val = pNext->val;
//待删除节点指针指后面第二个节点
pDelNode->next = pNext->next;
//删除待删除节点的下一个节点
delete pNext;
pNext = NULL;
}else if(*pHead == pDelNode)//删除的节点是头节点
{
delete pDelNode;
pDelNode= NULL;
*pHead = NULL;
} else//删除的是尾节点
{
ListNode *pNode = *pHead;
while(pNode->next != pDelNode) {
pNode = pNode->next;
}
pNode->next = NULL;
delete pDelNode;
pDelNode= NULL;
}
}
从尾到头打印链表
7.1 问题描述
输入一个链表,按链表值从尾到头的顺序返回一个 ArrayList 。
7.2 解法一
初看题目意思就是输出的时候链表尾部的元素放在前面,链表头部的元素放在后面。这不就是 先进后出,后进先出 么。
什么数据结构符合这个要求?
栈 !
代码实现
class Solution {
public:
vector<int> printListFromTailToHead(ListNode* head) {
vector<int> value;
ListNode *p=NULL;
p=head;
stack<int> stk;
while(p!=NULL){
stk.push(p->val);
p=p->next;
}
while(!stk.empty()){
value.push_back(stk.top());
stk.pop();
}
return value;
}
};
7.3 解法二
第二种方法也比较容易想到,通过链表的构造,如果将末尾的节点存储之后,剩余的链表处理方式还是不变,所以可以使用递归的形式进行处理。
7.3.1 代码实现
class Solution {
public:
vector<int> value;
vector<int> printListFromTailToHead(ListNode* head) {
ListNode *p=NULL;
p=head;
if(p!=NULL){
if(p->next!=NULL){
printListFromTailToHead(p->next);
}
value.push_back(p->val);
}
return value;
}
};
反转链表
8.1 题目描述
反转一个单链表。
示例:
输入: 1->2->3->4->5->NULL
输出: 5->4->3->2->1->NULL
进阶:
你可以迭代或递归地反转链表。你能否用两种方法解决这道题?
8.2 解题思路
设置三个节点pre
、cur
、next
-
(1)每次查看
cur
节点是否为NULL
,如果是,则结束循环,获得结果 -
(2)如果
cur
节点不是为NULL
,则先设置临时变量next
为cur
的下一个节点 -
(3)让
cur
的下一个节点变成指向pre
,而后pre
移动cur
,cur
移动到next
-
(4)重复(1)(2)(3)
8.3 动画演示
8.4 代码实现
8.4.1 迭代方式
class Solution {
public:
ListNode* reverseList(ListNode* head) {
ListNode* pre = NULL;
ListNode* cur = head;
while(cur != NULL){
ListNode* next = cur->next;
cur->next = pre;
pre = cur;
cur = next;
}
return pre;
}
};
8.4.2 递归的方式处理
class Solution {
public:
ListNode* reverseList(ListNode* head) {
// 递归终止条件
if(head == NULL || head->next == NULL)
return head;
ListNode* rhead = reverseList(head->next);
// head->next此刻指向head后面的链表的尾节点
// head->next->next = head把head节点放在了尾部
head->next->next = head;
head->next = NULL;
return rhead;
}
};
15. 双向链表、循环链表、LRU缓存淘汰算法
前几节学习了「链表」、「时间与空间复杂度」的概念,本节将结合「循环链表」、「双向链表」与 「用空间换时间的设计思想」来设计一个很有意思的缓存淘汰策略:LRU缓存淘汰算法。
循环链表的概念
如上图所示:单链表的尾结点指针指向空地址,表示这就是最后的结点了。而循环链表的尾结点指针是指向链表的头结点。
因此循环链表是一种特殊的单链表。它跟单链表唯一的区别就在于尾结点。它像一个环一样首尾相连,所以叫作「循环链表」。
循环链表的特点
和单链表相比,循环链表的优点是从链尾到链头比较方便,当要处理的数据具有环型结构特点时,适合采用循环链表。
双向链表概念
双向链表也叫双链表,是链表的一种,它的链接方向是双向的,它的每个数据结点中都包含有两个指针,分别指向直接后继和直接前驱。
所以,从双向链表中的任意一个结点开始,都可以很方便地访问它的前驱结点和后继结点。
双向链表的数据结构中,会有两个比较重要的参数: pre
和 next
。
-
pre
指向前一个数据结构 -
next
指向下一个数据结构
单链表与双链表的对比双向链表的特点
-
与单链表对比,双链表需要多一个指针用于指向前驱节点,因此如果存储同样多的数据,双向链表要比单链表占用更多的内存空间
-
双链表的插入和删除需要同时维护 next 和 prev 两个指针。
-
双链表中的元素访问需要通过顺序访问,支持双向遍历,这就是双向链表操作的灵活性根本
-
双向链表的基本操作
1.添加元素。
与单向链表相对比双向链表可以在 O(1) 时间复杂度搞定,而单向链表需要 O(n) 的时间复杂度。
双向链表的添加元素包括头插法和尾插法。
头插法:将链表的左边称为链表头部,右边称为链表尾部。头插法是将右边固定,每次新增的元素都在左边头部增加。
尾插法:将链表的左边称为链表头部,右边称为链表尾部。尾插法是将左边固定,每次新增都在链表的右边最尾部。
2.查询元素
双向链表的灵活处就是知道链表中的一个元素结构就可以向左或者向右开始遍历查找需要的元素结构。因此对于一个有序链表,双向链表的按值查询的效率比单链表高一些。因为,我们可以记录上次查找的位置 p,每次查询时,根据要查找的值与 p 的大小关系,决定是往前还是往后查找,所以平均只需要查找一半的数据。
3.删除元素
在实际的软件开发中,从链表中删除一个数据无外乎这两种情况:
-
删除结点中“值等于某个给定值”的结点
-
删除给定指针指向的结点
对于双向链表来说,双向链表中的结点已经保存了前驱结点的指针,删除时不需要像单链表那样遍历。所以,针对第二种情况,单链表删除操作需要 O(n) 的时间复杂度,而双向链表只需要在 O(1) 的时间复杂度。
双向循环链表的概念很好理解:「双向链表」 + 「循环链表」的组合。
缓存淘汰策略
缓存是一种提高数据读取性能的技术,在硬件设计、软件开发中都有着非常广泛的应用,比如常见的 CPU 缓存、数据库缓存、浏览器缓存等等。
缓存的大小有限,当缓存被用满时,哪些数据应该被清理出去,哪些数据应该被保留?这就需要缓存淘汰策略来决定。常见的策略有三种:先进先出策略 FIFO(First In,First Out)、最少使用策略 LFU(Least Frequently Used)、最近最少使用策略 LRU(Least Recently Used)。
在各个语言的第三方框架中都大量使用到了 LRU 缓存策略。程序员小吴接触到的有Java中的 「 Mybatis 」,iOS中的 「YYCache」与「Lottie」等。
LRU缓存淘汰算法
LRU是最近最少使用策略的缩写,是根据数据的历史访问记录来进行淘汰数据,其核心思想是“如果数据最近被访问过,那么将来被访问的几率也更高”。
将Cache的所有位置都用双链表连接起来,当一个位置被命中之后,通过调整链表的指向,将该位置调整到链表头的位置,新加入的Cache直接加到链表头中。
这样,在多次进行Cache操作后,最近被命中的,就会被向链表头方向移动,而没有命中的,而想链表后面移动,链表尾则表示最近最少使用的Cache。
当需要替换内容时候,链表的最后位置就是最少被命中的位置,我们只需要淘汰链表最后的部分即可。
链表实现LRU动画演示
-
如果此数据之前已经被缓存在链表中了,通过遍历得到这个数据对应的结点,并将其从原来的位置删除,然后再插入到链表的头部。
-
如果此数据没有在缓存链表中,可以分为两种情况:
-
如果此时缓存未满,则将此结点直接插入到链表的头部;
-
如果此时缓存已满,则链表尾结点删除,将新的数据结点插入链表的头部。
如果缓存空间足够大,那么存储的数据也就足够多,通过缓存中命中数据的概率就越大,也就提高了代码的执行速度。这就是空间换时间的设计思想。
对于程序开发来说,时间复杂度和空间复杂度是可以相互转化的。说通俗一点,就是:
-
对于执行的慢的程序,可以通过消耗内存(即构造新的数据结构)来进行优化;
-
而消耗内存的程序,可以通过消耗时间来降低内存的消耗。
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 全网最简单!3分钟用满血DeepSeek R1开发一款AI智能客服,零代码轻松接入微信、公众号、小程
· .NET 10 首个预览版发布,跨平台开发与性能全面提升
· 《HelloGitHub》第 107 期
· 全程使用 AI 从 0 到 1 写了个小工具
· 从文本到图像:SSE 如何助力 AI 内容实时呈现?(Typescript篇)