数组和链表
约瑟夫环问题
著名的约瑟夫问题:编号为 1-N 的 N 个士兵围坐在一起形成一个圆圈,从编号为 1 的士兵开始依次报数(1,2,3... 这样依次报数),数到 m 的 士兵会被淘汰出列,之后的士兵再从 1 开始报数。直到最后剩下一个士兵,求这个士兵的编号。
假设使用数组解决该问题,用一个长度为 N 的数组,索引表示 0~N-1 标号,要求返回最后一个士兵的编号。思路如下:
初始化剩余的士兵数 remain 为 n,重复下面操作,直到全部士兵淘汰。
初始化 count = 0,从第一个士兵开始计数,每次遍历到数值不为 -1 的元素,则 count+1,如果 count = m,说明该士兵需要被淘汰出局,则元素的值置为 -1,淘汰的数量 remain - 1,且 count 重新赋值为 0。
最后 remain = 0 时,此时的索引值便是最后剩下的士兵。
代码实现如下:
public static int josephus(int n, int m) {
int[] peoples = new int[n];
int index = -1; // 开始索引
int remain = n; // 环中剩余的人
int count = 0; // 循环数0-m
while(remain > 0) {
index++;
if(index == n)
index = 0;
// 跳过淘汰的人
if(peoples[index] == -1)
continue;
else {
count++;
if(count == m) {
peoples[index] = -1;
remain--;
count = 0;
}
}
}
return index;
}生成滑动窗口最大值数组
假设给定一个整形数组 nums 和一个大小为 k 的窗口,k 小于 nums 的长度,窗口从数组的最左边,每次滑动一个数,一直到最右边,返回每次滑动窗口中的最大值的数组。
假设输入数组为 {3, 5, -1, 3, 2, 5, 1, 6},窗口大小为 k = 3,滑动窗口具体如下,[]中即滑动窗口的内容:
首先是暴力解法,即每个窗口都遍历一次,找出最大值:
假设数组的大小为 n,窗口的大小为 k,那么滑动窗口个数就为 n-k+1,每次窗口比较获取最大数需要 k 次,所以一共要比较 k*(n-k+1) 次,时间复杂度为 O(n*k),空间复杂度为 O(N−k+1)。
要优化时间复杂度,需要以空间换时间,使用到双向队列,不需要每次全部统计新的滑动窗口的的最大值,只需要把更大值的索引不断地保存在队列的队尾中。具体思路如下:(队列中存的是数组的索引下标)
遍历数组 nums 中的元素 nums[i],执行以下操作。
队列的头部是最小元素,当队列不为空时,比较队列头的元素是否小于 nums[i],若是,则移除元素,直到队列头的元素大于 nums[i]。即删除队列中较小的元素索引。
将当前元素索引 i 添加到队列头部。
移除队列尾部已经不在窗口中的元素,即如果队列尾部元素小于等于 i-k,则移除尾部元素。
若当前索引达到窗口的大小,即 i=k-1 时,开始记录窗口中的最大值,即队列尾部元素。
以数组 {3, 5, -1, 3, 2, 5, 1, 6},窗口大小 k = 3 为例:
代码如下:
该算法最差的情况就是所有元素都进入队列,因此空间复杂度为 O(n),而最差的情况即所有的元素都进入队列之后,又挨个出队列,加入有 n 个元素,那么就进入队列和出队列的次数就是 2n,因此时间复杂度就是 O(2n)=O(n)。
找链表的倒数 k 个元素
定义链表的节点为 ListNode,里面包含了一个属性 val,表示当前节点的值,而另一个属性 next 则表示当前节点的下一个节点的引用,定义如下:
如果访问链表的顺序第 k 个节点,一般是给出头结点的引用,直接循环 k--,若当前节点下一个不为 null,则向后移动一位,直到 k = 1 结束:
但是如果改为获取倒数第 k 个元素,最简单的方法是先用一个指针从头到尾走完,得到链表的长度,然后再使用一个指针又从头开始,走到 n-k+1 的位置,就是倒数第 k 个元素。这样需要遍历两次。
我们可以让两次遍历一起进行,即两个指针,一前一后,让第 1 个指针先走 k 步,然后第 2 个指针开始与第 1 个指针一起走,直到第 1 个指针走到最后的位置,此时第 2 个指针停留的位置就是倒数第 k 个元素,具体代码如下:
判断一个链表是否有环
有两种情况:一种是链表整体就是个环,即环形链表,一种是局部形成环。问题是给出链表的头节点,判断链表是否有环。
最直接的方法是使用 HashSet,只需要遍历时判断是否成功加入该节点,若成功加入就后移指针,若添加节点失败,说明有环,返回 true,如果遍历到 null 节点,遍历结束,没有找到相同节点,即没有环的存在,返回 false:
这种方式需要借助额外空间,如果不借助 HashSet,可以使用快慢指针,让一个快指针每次走两步,慢指针每次走一步,如果两个指针能够相遇,说明快指针走过环已经从后面追上了慢指针,说明有环存在,若没有环,快指针会直接走到链表的尾部,到达 null 节点:
快慢指针的做法,没有使用额外的空间,因此空间复杂度为 O(1),时间复杂度同样是遍历链表的时间复杂度,也就是 O(n)。
找出环入口和计算环大小
给定一个链表,返回环的入口,如果不成环,则返回 null。
如果使用 HashSet 的方法,在判断是否包含相同的节点的时候,若相同,那该节点就是环得第一个元素。但是若用快慢指针,两个指针相遇的节点不一定是环的第一个元素。
可以证明,若使用快慢指针,两个指针相遇的位置到达环入口的距离,等于从起始位置到环的入口的距离。所以,此时让快指针回到链表的头结点,慢指针在当前相遇的位置不变,两个一起移动,每次移动的步长都为 1,则它们再次相遇的位置,就是环的入口位置。动态过程如下:
代码如下:
空间复杂度为 $O(1)$,时间复杂度为 $O(n)$。
再更改一下,给定一个链表,返回环的长度,如果不成环,则返回 0。
如果快慢指针相遇,说明有环存在,所以如果相遇后,一个指针继续走,一个指针不动,当再次相遇时,两个指针正好差了一圈,其中走的指针移动的次数就是环的长度:
时间复杂度为 $O(n)$,没有借助额外的空间,空间复杂度为 $O(1)$。
找两个链表的第一个公共节点
现有两个链表(没有环),但是存在着共同的部分,需要找出它们的第一个公共结点,如下图:
直接的方法是使用 HashSet,借助额外空间,先遍历添加第一个链表的所有节点,再遍历第二个链表,如果添加失败,就是第一个公共节点,时间和空间复杂度都是 $O(n + m)$ 级别。
如果不借助额外空间,可以使用双指针,可以发现从相同节点开始,后面的部分链表是相同的,所以如果从后面遍历,那么同时移动就会同时达到第一个公共节点,但是无法实现从后面开始遍历。所以要想办法从头遍历,也能同时到达公共节点。
注意到如果将两个链表各自都在最后将另一个链表拼接上,那么链表就等长了,而且相同的元素距离都是一样的,即两个指针移动相同的距离就会到达公共节点。
两个指针同时移动,当移动到 null 时,转向另一个链表的头部,开始移动,最后两个指针到达第一个公共节点移动的距离是相同的:
空间复杂度是 $O(1)$,时间复杂度为 $O(n + m)$。
翻转链表
给定一个链表,反转链表后,返回新链表的头节点。
方法是使用循环,不断把指向下一个的指针,指向前面的节点。假设链表是 1 -> 2 -> 3 -> 4,那么需要在开始时借助一个 null 节点,当 head 节点不为空的时候,先保存 head 的下一个节点,然后将 head 的 next 指向修改为指向反向,然后移动 head 指针:
直到 head 为 null 时,返回 first 即可:
操作指针时尤其注意要保存下一个指针的引用,否则会出现丢失引用的情况。
合并有序链表
有两个单调递增的链表,要把它们合成一个链表,合并之后的链表需要保持递增的性质,返回头节点。
这其实就是归并排序的 merge 步骤,创建一个新链表,同时遍历两个链表,选择元素较小的加入到新链表,并移动指针,直到有一个链表遍历完,然后再把没有遍历完的那个链表中的元素直接加到新链表尾部,代码如下:
时间复杂度是 $O(n + m)$,空间复杂度是 $O(1)$。
幸运数
幸运数是波兰数学家乌拉姆命名的,它采用与生成素数类似的“筛法”生成。
首先从 1 开始写出自然数 1,2,3,4,5,6,...,1 就是第一个幸运数。再从 2 开始,把所有序号能被 2 整除的项删除,变为:1 3 5 7 9 ...。
这时 3 为第2个幸运数,然后把所有能被 3 整除的序号位置的数删去。注意,是序号位置,非数本身,删除的应该是 5,11,17,...
此时 7 为第 3 个幸运数,再删除序号位置能被 7 整除的(19,39,...)。剩下的序列类似:
1, 3, 7, 9, 13, 15, 21, 25, 31, 33, 37, 43, 49, 51, 63, 67, 69, 73, 75, 79, ...
输入格式:两个正整数 m,n,空格分开(m < n < 1000*1000)。输出格式:位于 m 和 n 之间的幸运数的个数(不包含 m 和 n)。如:输入 1 20,输出 5;输入 30 69,输出 8。
由于需要不断删除元素,首先想到的数据结构是链表,可以使用现成的 LinkedList。
首先需要将除能被 2 整除之外的 1 ~ n 的数全部放到 list 中,然后从第二个元素开始取值,第一个元素是 1,没有意义。针对每个元素 num 执行以下操作:
初始化删除个数为 0,获取链表当前的长度为 size,从第一个元素开始,如果元素其序号能够被 num 整除,那么移除该元素。
按照元素的序号删除时,需要使用序号减去移除掉的元素,因为每移除一个元素,都会往前移动一位,否则会出现数组越界的情况。
删除掉之后,更新已经移除的元素个数。
最后判断 list 是否为空,若不为空,统计并返回 m 和 n 之间的元素个数。代码如下:
但是使用 LinkedList.remove(index) 是 $O(n)$,筛选过程较慢,而且 LinkedList 由于对象包装和维护数组的指针,实际占用内存较大。可以用一个 boolean[] 数组优化:
这样当 max 很大时,该方法在执行时间上要更优。