【OfferX】常见题目

编程要素

1.代码要完全对应思考的逻辑
每一个分支都要熟悉它是在做什么,对应的是哪一部分思考
2.考虑special case
常见的special case如下:
i.整数的最大值: 2147483647, -2147483648
ii.构成整数的数据,不能以0开头:179. Largest Number
iii.

3.sort的坑
当使用sort时,如果按照下面的方式实现排序,可能存在溢出的情况

Arrays.sort(nums, (int a,int b)->{ return a-b;});

因为如果a-b的值超过int的范围,就会发生反转,这里应当如下使用:

Arrays.sort(nums, Comparator.comparingInt((a)->a));

while(n-->0) 循环n次

Java常用的数据结构

数据结构类名支持操作内部实现
PriorityQueueremove() 弹出头部; remove(i) 移除元素–复杂度O(n)数组
二叉树TreeMap
队列Queueremove, removeLast()链表LinkedList
快速排序

二进制

1.位操作

n-1 将n的最右侧的第一个1以及余下的0取反

n&(n-1) 将n的最右侧都一个1置0

n&(n-1) == 0 判断一个数字是否是2的幂(是否只包含一个1)

n & (k-1) 求n%k,即模,k是2的幂。

n >>>k 即 n/(2^k),对2的幂做除法可以使用移位运算

c=0;while( (n>>>=1)!=0)++c; n是2的幂,求n的阶

n^0=n 0与任何数异或,结果不变

使用异或交换两个数字

int a=1,b=2;
a=a^b;
b=a^b;
a=a^b;

位运算细节:
n>>1 在java中,n是有符号数,则右移会导致补1
n>>>1 补0(尽量使用该操作)

2.数组中有一个数字出现了一次,其他数字都出现了两次,求这个数

使用异或运算,结果就是该数字。

3.数组中有两个数字出现了一次,其他数字都出现了两次,求这两个数

按第0位是0或1对数组中的数字进行分类,得两个分组,如果两个分组的数量是奇数,则说明两个数字被分配在不同的组里面,分别使用异或即可求得;否则,两个数字的尾号是相同的,可能存在于其中任何一组

然后按第1位是0或者1对数组进行分类,如果仍然是偶数,按第3位…重复下去,直到有一位能够将两个数分到不同的组(其实这个过程就是查找两个数的第一位不同)

最后找到两组都为奇数,然后使用异或即可求得两个数。

关键点:寻找两个数的第一个不同的数字出现的位置

加速:由于知道异或结果,所有可以快速找出第一位不同的位置,然后按这个位置进行分组即可。

4.数组中一个数字出现了一次,其他数字出现了三次,求出现一次的数字

仍然按第0位对数字进行分组,则数量mod 3=1的那一组就是该数所在的组,确定第0位

然后在剩下的组里面按第1位进行分组,数量mod 3 = 1的那一组仍然是该数所在的组,确定第1位

以此类推。

关键点:按位分组,确定数字所在的组

5.计算[0,n]上各个数的1的个数

n&(n-1) 可以将第一个1置为0,从而循环n次即可求得count(n)

所以存在递推: count(n) = count(n & (n-1)) + 1
可以使用缓存进行计算,只需要保证计算i时,小于等于i的所有数字都已经计算完成,从而在O(n)时间内完成计算。

6.不用比较,使用位运算返回a,b中的最大值

其实最大值由最高位1所在的位置决定

排序和查找

1.快速排序

以A[i]为基准元素,第1轮排序的结果是什么?
快速排序伪代码参考https://blog.csdn.net/xhdxhdxhd/article/details/104229669

2.二分查找

二分查找

5.归并排序

题目:合并K个排序的数组

3.分块查找

思想:首先

4.二叉查找树

关键点:极限情况下的数高度是n,所有退化为普通的有序表。

链表

1.反转链表

2.删除链表倒数第K个节点

关键点:删除单向链表节点的关键是定位到前一个节点
不考虑递归,使用循环,首先需要遍历链表获得链表大小n,重新遍历链表获得链表的倒数第K+1个节点,也就是指针移动 n - K - 1次。
如果使用递归,则需要在每个状态下记录当前位置i,在最终状态下记录n,则倒数第K+1个节点的位置 n - i = K + 1, i = n - K - 1

static n = 0
# 调用 removeLastKthNode(N,0)即可
removeLastKthNode(N,i):
    if N.next == NULL:
        n = i+1
    else:
        removeLastKthNode(N.next,i+1)
    if i == n - K - 1 and N.next != NULL:
        tmp = N.next
        N.next = tmp.next
        tmp.next = NULL

2.链表的环

题目:判断链表是否存在环, 并返回环的入口
第一步,使用两个指针,前者步长为2,后者步长为1遍历链表,如果有一个指针为空,则没有环;否则它们必然相遇在环中的某个点。

第二部,首先计算环的长度n,然后重新使用两个指针,第一个指针先走n步,然后它们以步长1遍历链表,最终相遇的点就是环的入口。

证明:待续
0 1 2 3 4 5 6 7 8 9 5 6 7 8 9

设环的长度是n,初始部分长度是k。

我们设指针在第i轮的位置是f(i)

(i>k)
显然 f(i,1) = (i - k + 1)%n

其实上面的算法还是复杂了,维护指针时,在快慢指针相遇时,只需要从头指针开始同步遍历直到相遇即可。

public class Solution {
    public ListNode detectCycle(ListNode head) {
        ListNode slow = head, fast = head, start = head;
        while (fast != null && fast.next != null) {
            slow = slow.next;
            fast = fast.next.next;
            if (slow == fast) {
                while (slow != start) {
                    slow = slow.next;
                    start = start.next;
                }
                return slow;
            }
        }
        return null;
    }
}

还可以考虑将链表反转

ListNode* reverseList(ListNode* head) 
{
	ListNode* prev = NULL;
	ListNode* follow = NULL;
	while (head)
	{
		follow = head->next;
		head->next = prev;
		prev = head;
		head = follow;
	}
    return prev;
}
bool hasCycle(ListNode *head)
{
	ListNode* rev = reverseList(head);
	if (head && head->next && rev == head)
	{
		return true;
	}
	return false;
}

4.两个链表的第一个公共点

需要从尾端对其两个链表,因此需要两个栈,来存储两个链表节点。然后从栈顶开始比较,记录最后相等的那个节点,就是公共点。

5.移除指定元素

题目:203. Remove Linked List Elements

:保持循环不变式:prev.next = head

class Solution {
    public ListNode removeElements(ListNode head, int val) {
        while(head!=null && head.val==val)head=head.next;
        if(head==null)return null;
        ListNode res = head;
        ListNode prev = head;
        head = head.next;
        // invariant: prev.next = head
        while(head!=null){
            if(head.val==val){
                head = prev.next = head.next;
            }else{
                prev = prev.next;
                head = head.next;
            }
        }
        return res;
    }
}

1.设计一个getMin,push, pop为O(1)时间的栈

对正常栈的状态进行记录,栈的栈顶元素称为状态,每个元素都记录其作为栈顶元素时的最小值。入栈时,根据加入的值与栈顶最小值比较,然后更新;出栈时,最小栈弹出栈顶元素。
第二个辅助栈称为最小栈。

2.使用两个栈实现队列

一个栈负责压入,另一个栈负责弹出。当需要弹出元素时,把第一个栈的所有元素都弹入第二个栈中,实现了顺序的逆转。
只要第二个栈有元素,就优先弹出。

1.从数组构建一个堆

堆有两个关键操作:1.冒泡(siftUp) 2.下沉(siftDown)

以最小堆为例,新增元素时,从最底部开始,向上冒泡,如果小于父元素,与父元素交换即可。此操作命名为siftUp

抽象几个操作: parent(i), left(i), right(i) 分别表示节点i的父节点,左子节点,右子节点
一种实现:数组arr[0]用于存储数量;
数组中父节点计算: parent(i) = i/2
左子节点:2i, 右子节点:2i + 1

伪代码:

siftUp(arr,i):
    while i > 1 and arr[i] < arr[i/2] :
        exchange arr[i], arr[i/2]
        i = i/2

siftDown(arr,i):
    while i < n/2:
        find min of arr[i], arr[2*i], arr[2*i+1] as arr[k]
        if k != i:
            exchange arr[i], arr[k]
            i=k
        else:
            break

二叉树

1.前序,中序,后序遍历确定位置

首先通过先序遍历遍历,确定根节点

2.构建遍历链表

关键点:使用静态变量记录遍历顺序

3.红黑树

红黑树的几种翻转操作, LL, LR, RL, RR等几种类型。

数组

1.最小的k个数

使用一个大小为k的最大堆维护最小的k个数,每当新增元素时,如果该元素小于最大堆的值,说明它更小,则移除头部,将其加入到堆中。

2.数组中出现次数超过一半的数字

解1:实际上,该数组的中位数一定是这个数字。所以使用一个最大堆和一个最小堆仿照上面的解法可以解决这个问题,只不过还有更优的解法。
解2:因为这个元素排序后一定出现在中间位置,所以实际上可以转化为寻找数组的第n/2个元素。设f(A,i,j,k)为在数组A[i,j]中寻找第k大的元素,则
如果将A[i,j]分区为[i,r],[r+1,j]两个部分,
如果[i,r]元素大于等于k,则重复子问题f(A,i,r,k)即可;
如果[i,r]元素小于k,则在右边查找, f(A,r+1,r,k-i+r-k)

约束条件:1<=k<=r-i+1
终止条件:i==j

使用Lomuto分区,可以保证每次确定第r个元素;使用Hoare分区则只能将问题规模降为1。
解3:(暂时没有理解为什么这样能够选择出)遍历数组,并记录最后一次出现最多的元素e和相应的次数,如果下一个元素和e相同,则次数增加;如果不同,如果次数大于0,则次数减1;如果次数等于0,则将次数设置为1,且更新结果为当前元素

遍历完成后,结果就是最后一次将次数置为1的元素。

3.数据流的中位数

中位数涉及中间的两个数据,因此肯定要对数组进行排序。
我们可以将排序后前一半元素存储在一个最大堆中,排序后的后一半元素存在在一个最小堆中,如果元素数量是偶数,则显然取两个堆的最大值和最小值取平均值即可;如果元素数量是奇数,我们将中间元素限定为包含在前一个堆中,因此中位数就是最大堆的的最大值。

由于数据流涉及更新,每新增一个数时,如果新增之前有偶数个元素,则将这个数加入到最大堆中;如果新增之前有奇数个元素,则将这个元素加入到最小堆中。

4.连续子数组的最大和

问题:求数组的连续子数组的和的最大值
:考虑A[i]和[i,j-1]的关系, 以A[i]结尾的连续子数组和不以A[i]结尾的连续子数组。因为连续子数组的性质就是,A[j]只能影响以A[j-1]结尾的子数组,因此其他的值都可以忽略。

f(j) 表示以A[j]结尾的最大连续子数组和
则 f(j) = max { f(j-1) + A[j], A[j] }
最终的最大值就是f(i)的最大值

5.数组中的逆序对数

问题:在数组中的A[i]和A[j] (i<j),如果A[i] > A[j],称其为逆序。求数组中的逆序对数

:考虑[0,i]区间中小于A[i+1]的元素数量,就是A[i+1]逆序数量。
因此,考虑使用一个二叉搜索树来确定元素的数量。

但是注意,手动实现一个平衡的二叉树相当困难,因此,本题可以使用归并排序的思想,归并排序需要额外的空间来存储合并的元素。

在分治的过程中,如果两边的数组已经排序完成,则统计逆序的十分方便。

6.剪绳子(切割问题)

题目:给定一段长为n的绳子,将其剪成m段,计算所有段段长的乘积,求最大值。
:考虑问题的第i段与第i-1段的关系,考虑第i段长为1的绳子片段,它可以选择与 i-1合并,也可以不合并。
使用f(i)表示解决[0,i]的问题,则
选择1:不与i-1合并
则第i段是游离的,长度为1,将此状态记录为g(i)表示第i段不与前i-1进行合并。由于i与i-1分离,所以绳子被分成两段,变成子问题: f(i-1)*1

选择2:与i-1进行合并
此状态即为h(i), 则 h(i)可以转化为f的问题
因为 i与i-1合并,所以末端的长度是2,此时剩余f(i-2)的问题,但是此时问题状态还包括右侧的长度,因此使用扩展f的状态f(i,k)表示解决 [0,i]的问题,且右侧还有k个长度
则h(i) = f(i-2,2)

由于扩展f后多出了k,所以需要对选择1和选择2重新考虑
若k==0,则选择1和选择2如上;若k>0, 则将k视为第i+1个元素即可

所以最终有:

f(i,k) = 
若 k \== 0: max {
    f(i-1,0)*1, 
    f(i-2,2)
}
若k>0,则将k视为一个单独的元素: max {
    不与i合并:  f(i,0) * k
    与i合并: f(i-1,k+1)
}

解2:上面的解法实际上过于复杂,实际上绳子一旦在某个点切断,就是两个独立的子问题了,因此有 f(n) = max { f(n-i) * f(i) }

解3:贪心算法
实际上由于乘法的性质,当n>=5时,比如n=6或者n=12, 只需要将绳子分成3段一份即可,将得到3^k;当n<=4时,不用切割即是最大值

关键点:切割问题可以划分为独立的子问题进行简单求解。

滑动窗口的最大值(单调栈)

问题:给定一个数组,大小为n,一个窗口m(m<=n), 在窗口滑动过程中会产生该区间内的最大值,求滑动产生的最大值。

:单调栈或者单调队列是解决这类区间最值问题的关键。
我们考察窗口队列中的最大值,因为队列在向前移动,每次会删除队首元素,加入新的元素。
考虑下面的高度序列,每一个高度代表数字大小

     |  
     |     
|    |              
|    |         |              |
|    |         |   |          |
|    |    |    |   |    |     |
0    1    2    3   4    5     6

如果窗口为3,第1个窗口A[1]是最大值
第2个窗口,A[1]仍然是最大值
第3个窗口,A[1]被移除。剩下的点中A[2]不可能成为最大值,因为在A[2]的右侧有大于A[2]的值存在,而[2,4]的窗口中最大值是 A[2]或者[2+1,4]的最大值。因此在比较过程中A[2]可以直接忽略掉。

更一般的说,在窗口[i,j]中,对于任意点k, 如果k的右侧存在比k更大的数,则k可以从比较队列中移除掉。

因此,算法的思想就是,维护一个待比较队列,每次窗口移动,
对于新加入的元素,它将会淘汰掉所有比较队列中小于等于它的元素。
因此,这个比较队列从最先加入的元素到后加入的元素,实际上形成了一个严格单调递减的序列
由于窗口移动需要移除区间最左侧的元素,因此需要使用一个下标记录最大值是否是队首的值,如果是,则需要将这个值也移除掉。

window-max(A,m):
    Q = {}
    for i=0 to m-1:
        while Q is not empty and Q.tail > A[i]:
            Q.removeTail()
        Q.add(A[i])
    maxValues = {}
    k=0
    maxValues[k]=Q.front
    for i=m to A.size-1:
        # 最大值得下标是待移除元素的下标
        if Q.front.index == i - m:
            Q.removeFront()
        while Q is not empty and Q.tail > A[i]:
            Q.removeTail()
        Q.add(A[i])
        maxValues[k++] = Q.front
    return maxValues

注意,也可以不必使用下标记录,我们将比较队列放宽为非严格单调递减,则只需要在移除队列是判断队首的值是否与待移除的值相等即可。如果队列中相同元素较多,则这里会浪费较多时间比较。如果相同元素较少,则该方法更加节省空间。

单调栈和单调队列:无论使用栈还是使用队列,都可以完成上面的问题。当使用栈时,只需要栈能够移除栈顶元素即可。相对而言,使用队列更加容易理解删除操作;使用栈更加容易理解比较过程。

问题2:选择数组中的一个区间,使得区间内最小值与区间的和的乘机最大

:该问题需要搜索数组,搜索的过程中,以某个元素为最小值,则这个最小值的所在的区间的两端肯定是比这个元素更小的值,因此我们可以使用单调队列来查找该元素的区间。

我们维护一个递增的单调序列,当遇到小于队尾(最新的元素)时,则该元素的区间就确定了(它的前一个元素与这个元素之间的区间),因此将这个元素从队尾移除。如此循环,可以遍历完所有的组合。复杂度:O(n)。

注意:实际上由于不需要对队首进行操作,因此使用单调栈更加简单。

问题3: 柱状图的最大矩形

问题4:柱状图的最大容纳面积

单调栈

单调栈解决问题使用的数据结构:一个队列,问题的状态包括已经出队的元素
单调栈的循环不变式:所有不在队列的元素已经解决

3.约瑟夫问题

题目:把0到n-1这n个数字排成一个圆圈,从数字0开始每次依次删除第m个数字,最后剩余的是哪一个数字?

递推公式:f(n,m) = [f(n-1,m)+m]%n (n>1), 如果n==1,则f(n,m)=0.

7.股票的最大利润

题目:把某支股票的价格按照时间顺序存储在数组中,买入和卖出该股票的最大价值是多少?比如 {9,11,8,5,7,12,16,14} 买入5,卖出16,利润11最大。
:只需要从数组的最左侧选择一个最小的数,然后从数组的最右侧选择一个最大的数,两者相减即是最大利润。
如何划分这些解呢?每一组解必然包含一个中间界限i,[0,i]的最小值和(i,n-1]的最大值,就是以i为界限的一个解。中间界限有 n-2个,所有只需要选择n-2个解里面得最大值即可。

简化:选择界限其实是存在重复的,以i为界限,如果A[i+1]比A[i]大,但是小于右侧最大值,则存在重复。
考虑以i为卖出点的最大值,显然只需要知道[0,i)的最小值即可求出。共有n-1个可行解,选择最大值即可。

9.递增数组中,求和为s的一对数

考虑f(i,j)表示 [i,j]内的解,考虑以A[i],A[j]作为其中一个解,则A[i] + A[j]是最大的那个;如果A[i]+A[j]=s,则成为解;如果A[i]+A[j] < s,则A[i]不可能是解,转化到f(i+1,j);如果A[i] +A[j] > s, 则A[j]不可能是解,转化到f(i,j-1)

即:

f(i,j) = [i,j] 如果A[i]+A[j] == s
f(i,j) = f(i+1,j) 如果A[i]+A[j] < s
f(i,j) = f(i,j-1) 如果A[i]+ A[j] > s

10.数组区间的最小值与和的乘积最大值

关键点:考虑数组的分区,以小于某个数可分成多个分区。
题目:给定一个数组,求一个区间[i,j]使得sum(i,j) * min(i,j) 具有最大值。
注:数组的每个元素保证在[0,100]范围内

解1:注意此题的一个显著的条件,元素值范围是[0,100]
我们考虑从0到100遍历即可。从解的角度考虑,我们需要一个最小值,然后在保证最小值的情况下求最大的和,然后求乘积。
从0到100依次假设最小值j,然后遍历数组A,每当遇到比j小的值时,结束一个区间的计算;直到遍历完所有的最小值,最终得到最大计算结果。

以j为最小值的视角: [A0,A1, … , j-1) [Ax, Ay, …,j, j-2)
可以看到这些区间实际上都是以小于j的值作为分界点的。

复杂度:O(100*N)

解2:遍历数组时,使用一个栈记录区间状态,栈顶元素是当前最小值的起始下标。如果当前元素比栈顶元素小,说明 [栈顶元素,当前元素) 构成了一个区间,此时可得一个候选解,然后将栈顶元素弹出,直到当前元素比栈顶元素大。这样可以保证栈顶元素到当前元素是以当前元素为最小值的最大区间。
关键点:使用单调栈形成隔离区间
区间性质:以 A[i]为最小值的最大区间中Zi,如果A[j]属于Zi且A[i]<A[j], 则A[j]的最大区间Zj是Zi的子区间。 因此,可以使用栈来记录嵌套区间,当子区间求完之后,父区间可继续延伸。

例如:5 9 6 8 7
栈的变化:
0: 5
1: 5 9
2: 5 6 弹出区间 [1,2)
3: 5 6 8
4: 5 6 7 弹出区间 [3,4)
5: 最后 弹出所有剩余区间 [4,5), [2,5), [0,5)

其结果就是:以A[i]为最小值的所有最大区间都能确定下来,从而能够确定 sum(i,j) * min(i,j)

复杂度:O(N)

11.移除数组连续元素获得的最大值

链接:https://leetcode.com/problems/remove-boxes/
不断从一个正数数组中移除k个相同的数,每一次移除获得k*k个价值,求最后将数组移除完毕后能够获得的最大价值。
示例:

[1, 3, 2, 2, 2, 3, 4, 3, 1]
----> [1, 3, 3, 4, 3, 1] (33=9 points)
----> [1, 3, 3, 3, 1] (1
1=1 points)
----> [1, 1] (33=9 points)
----> [] (2
2=4 points)
最终获得23

:考虑问题的空间状态,如果使用一个二进制数反映数组的元素是否存在,则问题状态图中,共有2^n个节点,节点之间的路径就是移除数目的平方,问题转化为求解从起始状态到终止状态的最大路径。
考虑对同一个元素而言,如果从一个数组中1个,和移除多个,显然一次移除多个的价值是最大的(因为 (a+b)^2 > a^2 + b^2), 所以每次可从数组中移除一组相邻的元素。
考虑移除第i组然后移除第i+1组元素,和先移除第i+1组,然后移除第i组,最终问题都转化成前i-1组的子问题,因此存在子问题重叠。
考虑f(i,j)表示移除[i,j]之间元素的最大值,则
若设r[j]为A[j]在[i,j-1]之间出现的所有位置。则A[j]可选择与r[j]中的任意元素进行进行合并。
由于合并后,末尾元素的数量会累积,所以使用g(i,j,s)表示求解[i,j]且A[j]数量为s的解;显然有
f(i,j) = max{ g(i,r,C[j] + C[r]) + f(r+1,j-1) }, r为[i,j-1]中所有与A[j]相同的下标
g(i,j,s) = max{ g(i,r,C[r]+C[r]) + f(r+1,j-1) }, r为[i,j-1]中所有与A[j]相同的下标

cache = new int[n][n]{ INF };
f(i,j):
    if i==j:
        return 1
    if cache[i][j] set:
        return cache[i][j]
    max = f(i,j-1) + 1
    for r=i to j-1:
        if A[r] == A[j]:
            sum = g(i,r,C[r] + C[j]) + f(r+1,j-1)
            if max < sum:
                max = sum
    return max
g(i,j,s):
    if i==j:
        return s*s
    max = f(i,j-1) + s*s
    for r=i to j-1:
        if A[r] == A[j]:
            sum = g(i,r,C[r] + s) + f(r+1,j-1)
            if max < sum:
                max = sum
    return max
    

解2:上面的解法实际上是存在冗余的,因为每次都需要试图匹配一个点,导致算法需要查找所有的点,如果我们将子问题改成,与左侧区间的一个合适的值匹配,而不必循环即可解决;则子问题1.不与左侧区间任何一个值匹配 2.与左侧第一个值匹配 3.与左侧区间的第一个值的左侧区间匹配

我们将普通问题表述为f(i,j), 即[i,j]的最大值,则问题2可使用h表述:

由于问题2产生匹配时,会累积边界的值,因此h需要3个参数,即h(i,j,m)
则 h(i,j,m) = h(i,r,m+1) + f(r+1,j-1)
表示子问题[i,r]和[r+1,j-1]
其中r为j的左邻接点

问题3假定使用g来表示,r表示j的左侧第一个邻接点;我们发现问题3实际上具有递归结构,因为左侧区间的左侧区间仍然构成一个子问题,则

g(i,j,r,m) = max { f(i,r,m+1,m) + f(r+1,j-1,m), g(i,j,r’,m) }
表示子问题由于左邻接点匹配的结果与左邻接点的左侧匹配的结果的最大值

g的示例图:

[i, ...  ,r',  r, ...  ,j]
j与r匹配
或者j与r左侧的点匹配,即j与r'或者r'的左侧的点匹配,具有问题g的结构
# prev[r]表示r的前驱下标
# 最终结果是 f(0,n-1,1)
f(i,j,m):
    if i>j:
        return 0
    if i==j:
        return m*m
    return max(f(i,j-1,1) + m*m, h(i,j,m), g(i,j,prev[j],m))
h(i,j,m):
    if i>j:
        return 0
    if i==j:
        return m*m
    return prev[j]<i ? 0 : f(i,prev[j],m+1) +  f(prev[j]+1,j-1,1)
g(i,j,r,m):
    if i>j || r<i:
        return 0
    return max(f(i, r, m + 1) + f(r + 1, j - 1, 1), g(i, j, prev[r], m))

注:上面的代码不经过优化,即使对f,h做缓存,运行时间仍然是204ms,离最优时间3ms相差甚远。
关键点:问题分类讨论,使用不同的符号表示不同的子问题。如果可能,合并不同符号减小子问题数量。

优化1:尝试合并子问题,第一步,去掉函数h,因为h只是单纯调用f。
优化后时间:160ms, 减少了40ms

优化2:使用g代表通用的转化形式
实际上,我们发现g才是函数的通用形态,f可以化成g的形式。令g能够解决点j不与[i,j]中的任何一个点合并的问题,然后将问题降低到二维并进行可能的剪枝。
当f全部化成g的形式调用之后,f不再需要m参数,因此缓存的空间降低一阶。

// dp[n][n]记录解
f(i,j):
    if i>j:
        return 0
    if i==j:
        return 1
    if dp[i][j] set:
        return dp[i][j]
    return dp[i][j]=g(i,j,prev[j],1)
g(i,j,r,m):
    if i>j:
        return 0
    if r<i:
        return f(i,j-1)+m*m
    if r+1==j:
        return g(i,r,prev[r],m+1)
    return max{g(i,r,prev[r],m+1) + f(r+1,j-1), g(i,j,prev[r],m)}     

上面的解最终的运行时间是2ms, 超过了100% 的解法。而其中最重要的一行代码就是

    if r+1 == j:
        return g(i,r,prev[r],m+1,sum)

这一步消除了许多无用的中间状态,达到剪枝的目的。
最终提交的java代码:

class Solution {
    int n;
    int[][] F;
    int[] prev;

    public int removeBoxes(int[] boxes) {
        n = boxes.length;
        prev = new int[n];
        F = new int[n][n];
        for (int i = 0; i < n; i++) {
            prev[i] = -1;
            for (int j = 0; j < n; j++) {
                F[i][j] = -1;
            }
            for (int j = i - 1; j >= 0; --j) {
                if (boxes[i] == boxes[j]) {
                    prev[i] = j;
                    break;
                }
            }
        }
        return f(0, n - 1);
    }

    public int f(int i, int j) {
        if (i > j) {
            return 0;
        }
        if (F[i][j] != -1) {
            return F[i][j];
        }
        if (i == j) {
            return F[i][j] = 1;
        }
        return F[i][j] = g(i, j, prev[j], 1);
    }


    public int g(int i, int j, int r, int m) {
        if (i > j) {
            return 0;
        }
        if (r < i) {
            return f(i, j - 1) + m * m;
        }
        // 如果相邻,可以消除许多中间状态
        if (r + 1 == j) {
            return g(i, r, prev[r], m + 1);
        }
        int rv = prev[r];
        return Math.max(
                g(i, r, rv, m + 1) + f(r + 1, j - 1),
                g(i, j, rv, m)
        );
    }
}

(此问题我抓狂了一天一夜 --2020年02月13日)

我之前的解析(2017年写的,稍微有点复杂):https://leetcode.com/problems/remove-boxes/discuss/101320/a-dp-solution-an-extra-thought-of-using-dancing-links/408318

12.判断数组中的元素是否只出现了一次

排序,然后统计。

如果要求尽量少的空间复杂度,我们我们将递归也考虑进去,则要排除快速排序。
所以,最终只有堆排序能够在O(1)的空间要求下完成O(NlogN)的时间复杂度。

13.选择小于k的数中最大的价值

题目:给定一个二维数组A, A[i][0] 表示选择i所需的最小值,A[i][1]表示选择i获得价值,给定值k,求能够获得最大价值。

链接:https://www.nowcoder.com/practice/46e837a4ea9144f5ad2021658cb54c4d?tpId=98&tqId=32824&tPage=1&rp=1&ru=/ta/2019test&qru=/ta/2019test/question-ranking

此题粗想不难,但是有许多细节值得注意。
首先,我们对数组按照A[i][0]进行升序排序,然后找到k在数组中的最接近的位置r,返回排序后小于等于r的最大价值。

为此,我们需要实现二分查找来获得k的下标,然后使用一个最大值数组记录区间最大值。

14.序列转换的次数

题目:已知一个奇怪的队列,这个队列中有n个数,初始状态时,顺序是1,2,3,4,…n,是1-n按顺序排列。这个队列只支持一种操作,就是把队列中的第i号元素提前到队首(1<i<=n),如有4个元素,初始为1,2,3,4,可以将3提前到队首,得到3,1,2,4 。 现在给出一个经过若干次操作之后的序列,请你找出这个序列至少是由原序列操作了多少次得到的。

链接:https://www.nowcoder.com/practice/02f2bbaadbcf424b8cbc7e264ddef9b4?tpId=98&tqId=33056&rp=1&ru=%2Fta%2F2019test&qru=%2Fta%2F2019test%2Fquestion-ranking&tPage=12

考虑序列[1,2,3,4,5], 它的下一个状态如下:
[2,1,3,4,5], [3 1 2 4 5], [4 1 2 3 5],[5 1 2 3 4]
不考虑状态的回退,这就意味着,后续的元素只能选择从右侧的单调递增区间中选择一个元素来变换,从而,我们可知,数组末端最长递增的序列就是所有无需调整的元素,而左侧的元素则是需要调整的。

因此,解就是统计数组末端的最长递增序列长度s,则需要调整n-s次。

15. k-sum

题目

图论

1.BFS和DFS

BFS的性质:在一个所有距离都为1的图中,能够找到任意两个点之间的最短路径。因为BFS的性质就是扩展。这个性质在格子地图的搜索中使用比较多,比如推箱子,迷宫问题。

BFS
广度优先搜索需要选择一个起点,其结果是该点能够到大的所有最终节点v,且形成的路径具有最少的边。
广度的含义:该算法仅在遍历完其距离为k的节点时,才会遍历距离为k+1的节点。
初始时,所有的节点标记为未访问;起点标记为已访问。维护一个队列表示将要访问的队列
伪代码

BFS(G,s):
    for v in G:
       visited[v]=false
       d[v] = -1
    Q = {s}
    d[s] = 0
    visited[s] = true
    while Q is not empty:
        u = Q.poll()
        for v in G.adj[u]:
            if not visited[v]:
                d[v] = d[u]+1
                visited[v]=true
                Q.add(v)

注意visited设置的时机,应当是在加入到队列之后就设置,不然可能存在重复入队的问题。

DFS: DFS对图进行深度优先遍历

DFS(G):
    for u in G:
        visited[u]=false
        d[u]=0
    t=0
    for u in G:
        if not visited[u]:
            DFS-VISIT(G,u)
DFS-VISIT(G,u):
    d[u]=t++
    visited[u]=true
    for v in G.adj[u]:
        if not visited[v]:
            DFS-VISIT(G,v)

遍历完成后,d将保存每一个节点遍历的先后顺序。

2.拓扑排序和最短路径

拓扑排序:是有向无环图的一个线性次序,所有的有向边都指向右侧。

排序算法:

topo-sort(G):
    dfs(G) with when each u is finished, insert u to front of list
    return list

如果存在环,则拓扑排序结果无效。

有向无环图的单源最短路径:对图的拓扑顺序进行遍历,我们可以知道排在前面的点不存在其他可能的路径,因此对其进行松弛后就是最短路径。
因为G.adj[u]只能是在拓扑顺序的后面位置

dag-shortest-path(G,w,s):
    list = topo-sort(G)
    init-single-source(G,s)
    for u in list:
        for v in G.adj[u]:
            relax(u,v,w)

正确性证明:对于图中的所有节点v,s到v的最短路径为 (v0,v1,…,vi) ,v0=s, vi=v; 则路径仍然是拓扑排序的,所以其松弛的次序为 (v0,v1), (v1,v2), … (vi-1,vi). 由于 (v0,v1)松弛后, v1不存在其他的可选路径,所以(v0,v1)是一条最短路径,同理 (v0,v1,v2)也是一条最短路径, … (v0,v1,…,vi)是一条最短路径
很显然,拓扑排序中在s前面的节点距离都是0,只有在s后面的节点才具有最短路径。

最短路径中的松弛性质:如果p=(v0,…vi)是一条最短路径,则只要保证松弛顺序是 (v0,v1), (v1,v2) … (vi-1,vi),则d(v0,vk)是v0到vk的最短路径。

3.Bellman-Ford算法求最短路径

解决一般情况下的单源单源路径问题,权重可以为负值。
Bellman-Ford算法的核心是最短路径松弛性质,设s到v的所有可能的无环路径为p0,p1,…pi, 则pi的最大长度是|G.V|. Bellman-Ford算法首先找到长度小于等于0的最短路径,也就是s本身;然后找到长度小于等于1的最短路径,依次找到长度小于等于|G.V|的最短路径。最终,得到路径距离。
但是注意,如果路径中存在负环,则最短路径是不存在的,这种情况下,只需要最后测试是否还能够对路径进行松弛,如果不能则找到最短路径;如果可以则存在负环

bellman-ford(G,w,s):
    init-single-source(G,s)
    for i=1 to |G.V|-1:
        for (u,v) in G.E:
            relax(u,v,w)
    for (u,v) in G.E:
        if can-relax(u,v,w):
            return FALSE
    return TRUE 

算法分析:复杂度 O(VE)
由于算法无差别地循环V-1次,每次需要遍历每条边,所以相对于Dijkstra算法而言,其效率更差。

SPFA 优化的Bellman-Ford算法

SPFA wiki
该算法使用一个队列保存所有产生了更新的节点,算法的每一步是从队列中取出一个点,然后更新其邻接点。
该算法的最坏情况和Bellman-Ford算法一样,O(VE).

spfa(G,w,s):
    init-single-source(G,s)
    Q = {s}
    d[s] = 0
    inQueue = {}
    inQueue[s] = true
    while Q is not empty:
        u = Q.poll()
        for v in G.adj[u]:
            if relax(u,v,w):
               if not inQueue[v]:
                   inQueue[v] = true
                   Q.add(v)
       inQueue[u] = false 

如果存在负环,则算法永远不会终止。因此,需要保证图G没有负环。
实际上,一个点加入到队列中最多等于它的邻接点个数,如果超过这个次数,说明该点存在负环。

4.Dijkstra最短路径

关键点:每次使用优先队列选择最短的路径进行松弛,松弛过程中还会调整优先队列
求解有向图非负权重的单源最短路径问题。
注:图的表示,V表示所有的点, G.adj[u]表示u的所有邻接点
伪代码:

# G图, G.w权重函数
dijkstra(G,s):
    init-single-source(G,s)
    Q = G.V 
    S = {}
    while Q not empty:
        u = extract-min(Q)
        S = S + {v}
        for v in G.adj[u]:
            relax(u,v,G.w)

# 初始化d, parent属性
init-single-source(G,s):
     for v in G.V:
         v.d = infite
         v.parent = NIL
     s.d = 0

# 更新v的路径和parent,u作为起点
# 由于这里要使用v.d,所以状态v是复用的,在某些情况下(比如推箱子),v需要使用一个map来记录d
relax(u,v,w):
    if v.d > u.d + w(u,v):
        v.d = w.d + w(u,v)
        v.parent = u

算法中优先队列有两个操作: 1.extract-min操作 2.decrease-key操作(即将路径松弛后,调整元素在队列中的位置)
复杂度取决于优先队列的实现:
1.如果优先队列使用平常的数组,则每次查找最大值都需要O(V)时间,总的复杂度是O(V^2).
2.如果是稀疏图,可以使用二叉堆来实现优先队列,extract-min=O(logV),decrease-key=O(logV), 总的运行时间 O((V+E)logV)
3.使用斐波那契堆,extract-min和decrease-key的摊还操作是O(logV),总复杂度是O(VlogV + E). 实际上,斐波那契堆的改进就来源于迪杰斯特拉算法中decrease-key操作远比extract-min操作频繁而来。

使用数组编写

public class Dijkstra{
    public int dijkstra(int[][] G,int s){
         int n = G.length;
         int[] d = new int[n];
         // 使用done[]标记已经完成的点
         boolean[] done = new boolean[n];
         for(int i=0;i<n;++i){
             d[i]=-1;
         }
         d[s] = 0;
         done[s]=true;
         for(int i=0;i<n;++i){
             // extract-min操作
             int min = -1;
             for(int j=0;j<n;++j){
                 if(!done[j] &&d[j]!=-1&& (min==-1 || d[j]<d[min])){
                     min = j;
                 }
             }
             done[min]=true;
             for(int k=0;k<n;++k){
                 if(G[min][k]>0){
                     // relax
                     if(d[k]==-1 || d[k] > d[min] + G[min][k]){
                         d[k] = S[min] + G[min][k];
                     }
                 }
             }
         }
    }
}

关键点:使用done数组标记已经完成的点避免重复查找;extract-min操作需要排除已经完成的点

2.TSP旅行商问题(TSP)

题目:有n个城市,任意两个城市之间距离为d[i,j]。如果希望从城市0出发走遍每一个城市且每个城市仅路过一次,最后回到城市0,求最小路径。
:该问题实际上可以抽象为下面的子问题:设当前处于i城市,未访问的城市集合是V,求从i出发,经过V的所有节点,返回城市0的最小路径。
记为f(i,V), 则

f(i,V) = min{ div + f(v,V-{v}) }, 当V为空时, f(i,V) = di0

限定V的数量为20个,则可以使用一个int类型和二进制来记录。
使用数组记录,内存在M级别。

强连通分量(component)

定义:在有向图中,强连通分量是指图的一个子图,该子图中,任意两个节点都能够相互到达。

可以将图拆分成若干个强连通分量,将这些连通分量视为一个点,图就收缩为一个有向无环图。

算法:对于图G, G’表示G的转置,也就是将G中的每一边都反转。

STRONGEST-CONNECTED-COMPONENT(G):
    dfs(G), get u.f for each vertex u in  G
    dfs(G') in decreasing order of u.f:
        then each tree in dfs(G') is a strongest-connected-component

第一步DFS先找到所有点的拓扑排序,然后第二步DFS根据拓扑排序的倒序结果,寻找所有点的起点,最终获得连通分量。

3.最小生成树(MST)

定义:在一个无环图中,寻找一条线将图中的所有点都连接起来,并且权重和最小。
因为这些点连起来以后必然形成一棵树(没有回边),从而将这个这棵树称为最小生成树。

通用MST算法:

GENERIC-MST(G,w):
    A = {}
    while A is not a spanning tree:
        find and edge (u,v) that is safe for A
        A.add({u,v})
    return A

循环不变式:每次循环开始时,A是某棵最小生成树的子集

因为每次循环只加入了一条安全边,所以加入的安全边仍然构成了一个最小生成树。算法的关键是寻找一条安全边。

Kruskal算法和Prim算法符合该基本算法。
Kruskal算法:维护一个生成树的集合,每次选择连接两棵生成树的最小边,然和合并这两棵树。
使用并查集:

MST-KRUSKAL(G,w):
    A = {}
    for v in G.V:
        MAKE-SET(v)
    sort G.E in non-decreasing order
    for (u,v) in G.E taken in non-decreasing order:
        if FIND-SET(v) != FIND-SET(u):
            A.add({u,v})
            UNION(u,v)
    return A

Prim算法:从某个节点开始生成一颗最小生成树A,维护一个尚未加入A中的集合Q,Q的所有点记录了它们与A连接的最小值。每次循环时,从Q中取出连接权重最小的那个,然后更新其他Q中相邻的点的权重。

该算法和dijkstra算法类似,每次从未解决的点中选择一条最小的边加入最小路径,然后更新相邻的点。

MST-PRIM(G,w,r):
    for v in G.V:
        u.key = INF
        u.parent = NIL
    r.key = 0
    Q=G.V
    while Q is not empty:
        u = EXTRACT-MIN(Q)
        for v in G.adj[u]:
            if v in Q and w(u,v) < v.key:
                v.parent = u
                v.key = w(u,v)

不能存在负向环

算法

1.一系列数据中有一个数出现了k次,求这个数

2.并查集

并查集可以快速判断两个

关键点:1.支持union和find两个操作 2.每个点使用属性p表示归属的集合,根节点的p属性等于其自身
伪代码

makeSet(x):
    x.p = x

find(x):
    while x!=x.p:
        x=x.p
    return x

union(x,y):
    find(x).p = find(y)

带有秩优化的find操作:

find(x):
    if(x.p==x)return x;
    return x.p=find(x);

矩阵中的并查集问题(TODO 提交)

题目778. Swim in Rising Water

在一个矩阵中G中, G[i][j]表示该点的海拔,在t时刻下雨,G[i][j]的高度是 max{t, G[i][j]}. 现在你处于G[0][0],你可以在0时刻内游到任何高度不超过t的格子中, 经过最少多少时间后可以游到G[n-1][n-1]?

:判断在给定t时间内,矩阵是否能够从G[0][0]到大G[n-1][n-1],由于t的最小值是1,最大值是N^2,所以使用二分查找来确定t的最小值。

Number of Islands

题目200. Number of Islands(Medium)

给定一个矩阵G[n][m], G[i][j]=1表示岛屿,G[i][j]=0表示水,求岛屿数目

:此题可使用传统的并查集来解决,如下:

class Solution {
    
    int[] prev;
    int c;
    
    int find(int x){
        if(x==prev[x])return x;
        return prev[x]=find(prev[x]);
    }
    void union(int x,int y){
        int px=find(x);
        int py=find(y);
        if(px!=py){
            --c;
            prev[px]=py;
        }
    }
    
    public int numIslands(char[][] grid) {
        int n=grid.length;
        if(n==0){
            return 0;
        }
        int m =grid[0].length;
        
        prev = new int[m*n];
        c=0;
        
        for(int i=0;i<n;++i){
            for(int j=0;j<m;++j){
                prev[i*m+j] = i*m+j; 
                if(grid[i][j]=='1'){
                    ++c;
                }
            }
        }
        
        for(int i=0;i<n;++i){
            for(int j=0;j<m;++j){
                if(grid[i][j]=='1'){
                    if(i-1>=0 && grid[i-1][j]=='1'){
                        union(i*m+j,(i-1)*m+j);
                    }
                    if(j-1>=0 && grid[i][j-1]=='1'){
                        union(i*m+j,i*m+j-1);
                    }
                }
            }
        }
        return c;
    }
}

但是更快的方法是直接在数组中修改集合关系,每当我们遇到1时,我们就清除周围的1,直到结束。因为1是连续的,我们往四个方向清除之后,保证这个集合的1不会再被访问,因此每次遇到1都是集合数量。这种方法的复杂度是O(NM).

public class Solution {
    public int numIslands(char[][] grid) {
        int count = 0;
        for (int i = 0; i < grid.length; i++) 
            for (int j = 0; j < grid[i].length; j++)
                if (grid[i][j] == '1') {
                    count++;
                    clearRestOfLand(grid, i, j);
                }
        return count;
    }
    
    private void clearRestOfLand(char[][] grid, int i, int j) {
        if (i < 0 || j < 0 || i >= grid.length || j >= grid[i].length || grid[i][j] == '0') return;
        grid[i][j] = '0';
        clearRestOfLand(grid, i+1, j);
        clearRestOfLand(grid, i-1, j);
        clearRestOfLand(grid, i, j+1);
        clearRestOfLand(grid, i, j-1);
    }
}

递归解法还可用于此题: 695. Max Area of Island(Medium)

class Solution {
    int[][] grid;
    public int maxAreaOfIsland(int[][] grid) {
        this.grid = grid;
        int max = 0;
        for(int i=0;i<grid.length;++i){
            for(int j=0;j<grid[i].length;++j){
                if(grid[i][j]==1){
                    max = Math.max(findClearAll(i,j),max);
                }
            }
        }
        return max;
    }
    
    int findClearAll(int i,int j){
        if(i<0||i>=grid.length||j<0||j>=grid[i].length||grid[i][j]==0)return 0;
        grid[i][j]=0;
        return 1+findClearAll(i-1,j) + findClearAll(i+1,j) + findClearAll(i,j-1) + findClearAll(i,j+1);
    }
}

Friend Cycle

题目547. Friend Circles(Medium)

给定一个关系矩阵G[n][n], G[i][j]=1表示i和j存在朋友关系, 如果i,j存在朋友关系, j,k存在朋友关系,则i,k存在间接朋友关系。请找出所有的朋友圈,每个朋友圈中的两个人要么是朋友关系,要么是间接朋友关系

:这道题说白了还是考察并查集的内容,因为i要在一个朋友圈中,i必然与朋友圈中的一个人是直接朋友关系,所以只需要把所有具有朋友关系的人并在一起即可。

实际上,间接朋友关系对本题没有任何影响。

class Solution {
    
    int[] prev;
    int c;
    
    int find(int x){
        if(prev[x]==x)return x;
        return prev[x]=find(prev[x]);
    }
    void union(int x,int y){
        int px=find(x);
        int py=find(y);
        if(px!=py){
            --c;
            prev[px]=py;
        }
    }
    
    public int findCircleNum(int[][] M) {
         // in a cycle, person A must have a direct friend.
          
        // c=N
        // for each person A
        //     makeSet(A)
        
        // for each person A
        //    for each friend  B:
        //          if A,B not the same
        //           union(A,B)
        //           --c
        // return c
        int n=M.length;
        prev = new int[n];
        c=n;
        for(int i=0;i<n;++i){
            prev[i] = i;
        }
        for(int i=0;i<n;++i){
            for(int j=i+1;j<n;++j){
                if(M[i][j]==1){
                    union(i,j);
                }
            }
        }
        return c;
    }
}

3.整数的平方

4.背包问题

题目1: 给定体积下的放置方式 链接

给定一个背包容量为w, 共有n个物品,每个物品的体积是v[i],求总体积不超过w的情况下,有多少种放置物品的方法?(体积为0也是一种方法)

:f(A,w) = sum { f(A - {i}, w - v[i]) + f(A -{i},w) }
由于子问题无论如何都会去掉i,因此我们可以将问题简化为求 [i,n-1]的区间的背包问题
f(i,w) = f(i+1, w-v[i]) + f(i+1,w)

缓存使用map记录w对应的值。

回溯法

0.一般应用背景

棋盘,或者数组规模小于等于9时

1.八皇后问题:棋盘上八个皇后的布局

对每一个格子(x,y), 如果该点被占用,则上它标记了纵线,正斜线和反斜线三条线上的格子不能用
因此进入下一层时所有的点只需要判断它的纵线,正斜线和反斜线是否未被占用即可。

计算(x,y)的正斜线和反斜线的位置

|
|\
|  \
|_ _ _ _ _ _ _ _ _

左斜线直线组: y = -x + T, T从0到2*(n-1)
右斜线直线组:y = x + T, T从-(n-1)到(n-1)

所以,左斜线的序号: y+x, 右斜线的序号:y-x + (n-1)

注意事项:1.代码中以int search(i)为原型,表示当前状态下搜索第i层产生的解,注意i==n时返回1

代码:

vertical = new boolean[n];
left = new boolean[2*n - 1];
right = new boolean[2*n - 1];

public static int search(int i){
    if(i==n){
        return 1;
    }
    int count = 0;
    for (int j = 0; j < n; j++) {
        if(vertical[j] || left[i+j] || right[i - j + n - 1]){
            continue;
        }
        vertical[j] = left[i+j] = right[i-j +n-1] = true;
        count += search(i+1);
        vertical[j] = left[i+j] = right[i-j +n-1] = false;
    }
    return count;
}

2.迷宫问题

题目:给定一个二维数组,其中0表示通道,1表示围墙,指定起点和终点,寻找一条通道。
:路径是不存在重复的,因此如果一个点已经走过,它不可能重新成为路径的一部分。所以搜索时需要将路径上的点标记为已访问。
当前点如果不是终点,则将邻接的可用的点加入到栈中,依次进行搜索。

剪枝:如果一个点已经走过,则它的两个相邻点只能有一个成为解的一部分。因为如果两个点都在路径上,则选择路径靠后的一个,前面的点就被消除了。所以在回溯时,无需考虑将邻接点的状态从已用转化为可用。因此下面的代码中,首先将所有的可用邻接点标记收集起来,然后标记为已用,再进行回溯。这样能够避免路径上出现同一个点的两个邻接点。非常重要的一点是:在邻接点遍历完毕后,应当将所有的点重新标记为可用,因为它们只是在邻接点期间不可用。

伪代码:

# t 终点
solution = {s}
search(s):
    if s==t:
        print(solution)
        return
    avlAdj = []
    for q in s.adj:
        if q.used==0:
            continue
        q.used = 1
        avlAdj.add(q)
    for q in avlAdj:
        solution.add(q)
        search(q)
        solution.removeLast()
    # 为了找出所有的解,这里需要进行恢复
    for q in avlAdj:
        q.used = 0

java代码,打印所有的路径

import java.util.ArrayList;
import java.util.List;
import java.util.Scanner;

public class Solution {
    private static int n;
    private static int m;
    private static int[][] arr;
    private static int[] start;
    private static int[] end;

    public static void main(String[] args) {
        Scanner scanner = new Scanner(System.in);
        n = scanner.nextInt();
        m = scanner.nextInt();

        start = new int[] { scanner.nextInt(),scanner.nextInt()};
        end = new int[] { scanner.nextInt(),scanner.nextInt()};
        arr = new int[n][m];
        for (int i = 0; i < n; i++) {
            for (int j = 0; j < m; j++) {
                arr[i][j] = scanner.nextInt();
            }
        }

        /*
        5 5
        0 4
        4 4
        0 0 1 0 0
        0 0 0 0 0
        0 0 0 1 0
        1 1 0 1 1
        0 0 0 0 0
         */
        // [down, left, left, down, down, down, right, right]
        // [left, down, left, down, down, down, right, right]
        // true
        Solution solution = new Solution();
        System.out.println(solution.hasPath(arr,start,end));
    }

    class Pack{
        int[] data;
        String dir;

        public Pack(int x,int y,String dir) {
            this.data = new int[]{x,y};
            this.dir = dir;
        }
    }

    List<String> solution = new ArrayList<>();
    public boolean hasPath(int[][] maze, int[] start, int[] destination) {
        if(start[0]==destination[0] && start[1]==destination[1]){
            System.out.println(solution);
            return true;
        }
        List<Pack> points = new ArrayList<>(4);
        if (start[0]>0 && maze[start[0]-1][start[1]]==0){
            points.add(new Pack(start[0]-1, start[1],"up"));
        }
        if(start[0]<maze.length-1 && maze[start[0]+1][start[1]]==0){
            points.add(new Pack(start[0]+1, start[1],"down"));
        }
        if(start[1]>0 && maze[start[0]][start[1]-1]==0){
            points.add(new Pack(start[0], start[1]-1,"left"));
        }
        if(start[1]<maze[0].length-1 && maze[start[0]][start[1]+1]==0){
            points.add(new Pack(start[0], start[1]+1,"right"));
        }
        for (Pack point : points) {
            //visited
            maze[point.data[0]][point.data[1]]=1;
        }
        boolean once = false;
        for (Pack point : points) {
            solution.add(point.dir);
            once |= hasPath(maze,point.data,destination);
            solution.remove(solution.size()-1);
        }
        for (Pack point : points) {
            maze[point.data[0]][point.data[1]]=0;
        }
        return once;
    }
}

题目链接
https://leetcode.com/articles/the-maze/

https://leetcode-cn.com/problems/word-search/?utm_source=LCUS&utm_medium=ip_redirect_q_uns&utm_campaign=transfer2china

3.推箱子

链接:https://leetcode.com/problems/minimum-moves-to-move-a-box-to-their-target-location/
题目:在一个NxN的地图上,有一个箱子,一个目的地,一个人,以及一些障碍物,计算出最少需要多少步将,人能够将箱子推到目的地。
解1:该问题实际上比迷宫的问题复杂。将人和箱子的坐标视为状态 (xp,yp), (xb,yb),则最终状态就是xb=终点坐标,yb=终点坐标。从图论的角度来看,节点与节点之间的距离是0或者1,而且有多个终点。可以使用dijkstra算法求解最短路径,然后选择其中箱子位置与目标位置相同的节点的最小值即可。实际上如果是求最短路径,则第一个出现的状态就是目标状态。

基于Dijkstra算法,如果使用数组则需要遍历很多无用状态,因此需要使用队列。队列中存储状态,不存储解,解使用其他的map存储。
伪代码:

search(p,b,t):
    Q = {}
    disMap = {}
    moveStep = {}
    visited = {}
    parent = {}
    initState = {player:p, box:b}
    Q.add(initState)
    disMap[initState]=0
    visited[initState]=true
    while !Q.empty():
        u = Q.poll()
        visited[u]=true
        if u.box == t:
            printSolution()
            return
        for v in move(u):
            if disMap[v.state] == NIL || disMap[v.state] > disMap[u] + v.delta:
                moveStep[v.state] = v.name
                parent[v.state] = u
                Q.remove(v.state)
                disMap[v.state] = disMap[u]+v.delta
                Q.add(v.state)
move(u):
    for i in u.adj:
        # 这里必须排除那些已经访问的点
        if visited[i]:
            continue
    返回所有相邻节点,每个节点包含 state={player:,box:}, delta=移动步数, name=移动名称

注意:必须使用visited限定move的节点。

分析:dijkstra算法使用数组实现会比使用普通队列慢,但是复杂度增长是一样的; 因此在推箱子的问题中,虽然dijkstra算法易于实现,但是效果不佳;除非能够使用斐波那契数堆。

解2:优化的Dijkstra算法
因为我们知道所有可能的路径介于0到n*m之间(路径最多包含所有的格子),因此在记录最小路径时,可以使用一个Tree来存储路径i的所有状态。则每次从Tree中取出最小的路径i以及对应的点集Q,则点集Q的所有点的路径都已经确定了,将它们加入到visited中

对点集Q的每一个相邻点P,如果P不在visited中,则进行下面的步骤:
因为相邻点的距离只有0和1,如果是0,则将P加入visited中;如果是1,将P放到Tree的路径为i+1的集合中

所以,实际上整个问题变成:寻找最短路径=i (0 <= i <= n*m)对应的所有点集。
由于最短路径的下一个状态要么是0,要么是1,所以只需要维护一个集合即可,不需要visited等状态。

search(p,b,t):
    for each state:
        inQueueOrSolved[state]=false
    Q = { (p,b) }
    inQueueOrSolved[(p,b)]=true
    # 当前路径
    i = 0
    while Q is not empty:
        N = {}
        for u in Q:
            if u==t:
                return i
            for (v,s) in move(u):
                inQueueOrSolved[v]=true
                if s==0:
                    Q.add(v)
                else:
                    N.add(v)
        ++i
        Q = N

move(u):
    需要排除inQueueOrSolved的状态  

循环不变式:queue中保存所有已解决的路径为i的点
所有可能移动的点可能是:前一层已访问的点(忽略),同一层已访问的点(忽略),同一层未访问的点,加入到当前层中;下一层未访问的点,加入到下一层。

注意,当我们将一个点加入到下一层时,实际上就已经决定了它的距离至少是i+1。那么,是否存在一个点违反这个性质呢?由于队列中所有的状态都是唯一的,因此所有状态的能够推动箱子的状态是唯一的。

解3:对状态图进行BFS,并使用队列存储产生更新的点
循环不变式:每次循环时,队列中包含所有已产生距离更新的节点
为了避免重复更新,同一个节点不能被加入队列中两次,所以需要一个状态记录是否在队列中(是否产生了更新)
当到达t节点后,无需将t节点放入队列中,因为如果其他节点能够更新t的距离,则删除重复的路径,总能获得更短的距离。
循环终止条件:队列中没有新产生更新的节点时,则已求得最小距离

bfs-box(p,b,t):
    for each x as player:
        for each y as box:
            inQueue[(x,y)] = false
            d[(x,y)] = INF
    Q = {(p,b)}
    # d[x]是当前已经计算的宽度树高度下的最小距离
    d[(p,b)] = 0
    ans = INF
    while Q is not empty:
        u = Q.poll()
        inQueue[u] = false
        if u[1] == t:
            if ans > d[u]:
                ans = d[u]
            continue
        # 这里比较关键,每当一个状态的距离被松弛以后,需要将其重新加入队列中,后续会更新该节点的最小值
        for (v,s) in move(u):
            if d[u] + s > d[v]:
                d[v] = d[u] + s
                if not inQueue[v]:
                    Q.add(v)
                    inQueue[v]=true
                
move(u):
    返回4个邻接点

注意,该算法同样适用于求最短路径。
上面算法的运行时间实际上会比较长,因为一个点会被反复加入到队列中更新直到它的路径不能够再松弛位置。
因此我们也可以将算法描述为:找到每个可到达的点的所有松弛路径中的最小值
首先是确保每个点都会被访问到,通过move函数保证
然后确保所有松弛路径,也是通过move函数的四个相邻状态保证的。

while 队列中存在产生新的松弛距离的点:
    更新其相邻的点的松弛距离,将产生更新的点加入到队列中

解4:减小BFS的状态数,分别对人和箱子进行BFS
人的位置需要BFS到箱子的4个相邻位置,只需要判断是否能够进行即可,每个箱子的位置有4个状态,表示人所处的位置。箱子则需要在4个状态下,搜索到目标的4个状态之一。

divided-bfs(p,b,t):
    Q = {}
    for dir in dirs:
        p = (b,dir,0)
        if not search-player(player,p.player):
            continue
        Q.add(p)
        visited[p.state]=true
    while Q is not empty:
        u = Q.poll()
        if u.box == t:
            return u.d
        for v in move-box(u):
            visited[v] = true
            Q.add(v)
            v.d = u.d + 1
      
# u包含箱子位置和人的相对位置    
move-box(u):
    list = positions of u
    for p in list:
        p.available = not visited[p.state] && search-player(u.player,p.previousPlayer)
    return list
          
# 搜索人从p到达s的路径 
search-player(p,s):
    if p==s:
        return true
    if s is invalid:
        return false
    for each state:
        visited[state]=false
    Q = {p}
    visited[p]=true
    while Q is not empty:
        u = Q.poll()
        if u==s:
            return true
        for v in move-point(u):
            if not visited[v]:
                visited[v]=true
                Q.add(v)
   return false

复杂度分析:O(V^2)

4.魔方

题目:给一个2x2的迷你魔方每个面编号,赋予一个数字;最多转动这个魔方n次,求魔方的每一面4个数字的积的总和的最大值。
:使用回溯法,主要是状态转化有点麻烦,纯手写就行。

5.俄罗斯方块

6.拼图游戏

7.五子棋

题目:判断五子棋哪一方赢棋

:五子棋需要8个方向是否满足赢棋规则,因此需要统计一个棋子的8个方向的累积和。8个方向的累积和各有具有迭代性质,因此,我们遍历某个方向时,需要保证下一个点的累积数据一定能够根据之前的累积数据直接计算出来。

我们使用的遍历方法: 左上角 --> 右下角:解决左,左上,上的统计, 右上角-->左下角:解决右,右上的统计, 以及左下角->右上角右下角-->左上角的统计。

此外,我们可以初始化一个nn8的数组,一次遍历解决,只需要在一次遍历中执行8个更新操作即可。
或者我们执行n*n次循环,单独维护四个方向的遍历即可。

# points和dirs存储
dirs=[
   [
    (-1,0,0), // 左  第三个下标表示的是sum中的位置
    (-1,-1,1), // 左上
    ]
    ...
    (1,1), // 右下
]

points = [(0,0),(0,n-1),(n-1,0), (n-1,n-1)]
sum = new int[n][n][8]
for i = 1 to n*n:
    for j = 1 to 4:
        x,y = points[j]
        for dir in dirs[j]:
            prevX,prevY = x+dir[0],y+dir[1]
            prevSum = 0
            if prevX,prevY is valid and the same color:
                prevSum = sum[prevX][prevY][dir[2]]
            sum[x][y][dir[2]]=prevSum+1
            if prevSum+1 >= 5:
                print("win")
        points[j] = next(points,j)

其他还需要注意,黑棋和白棋至多相差一个子,且累积的数目至多是5。

5.数独

题目:数独是一个9x9的宫格里,每一行,每一列,每个3x3的格子(一共9个)中1-9只能出现一次
链接https://leetcode-cn.com/problems/sudoku-solver/
在这里插入图片描述
在这里插入图片描述
解法1:使用回溯法依次判断每一个空格。
优化方案:减少无效的重试,当一个宫格使用之后,该行的所有格子,该列的所有格子,该3x3宫格的格子可使用候选就减少了。一共有9行,9列,9个3x3格子,每一个格子都有其所属的行,列和3x3格子下标。

使用一个3x9的数组,记录行、列、3x3格子的占用情况。
解法2:使用Dancing Links
Knuth提出了解决精确覆盖问题的方案,所谓精确覆盖是指给定多个限制条件和选择范围,选择出所有的解。
首先选择一列,然后选择选择相交的行,移除这个行的所有已选择的列,同时移除关联这些列的行。

https://github.com/rafalio/dancing-links-java/blob/master/www-cs-faculty.stanford.edu/~uno/papers/dancing.ps.gz

精确覆盖问题,考虑下面的矩阵,找出其中能够覆盖所有列都有1的所有行。比如1,4,5行。
在这里插入图片描述

AlgorithmX:对于一个矩阵A
1.如果A是空的,则已经产生了解
2.如果A非空,则选择一列c
3.选择这一列中的一行r, 是的A[r,c]=1
4.对行r的每一列j, 如果A[r,j]=1
从矩阵A中删除列j
对j的每一行i, 如果A[i,j]=1
从矩阵A中删除行i
5.在减小的矩阵上继续应用该算法

矩阵的每一个点使用4个节点连接
在这里插入图片描述
使用数组来存储矩阵,令U[x],D[x],L[x],R[x]分别表示上邻接点,下邻接点,左邻接点,右邻接点。C[x]表示该点的列头,S[x]表示该列的大小,N[x]表示该列的名称。

列头的L,R链接了所有仍需解决的列(约束条件)。
一个特殊的点:h,表示头部,仅L[h], R[h]有效。

伪代码

search(k):
    if h.R == h:
        print solution
        return
    c = choose_column()
    cover(c)
    r=c.D
    while r!=c:
        O[k] = r
        j=r.R
        while j!=r:
            cover(j)
            j = j.R
        search(k+1)
        r=O[k]
        c=r.C
        j=r.L
        while j!=r:
            uncover(j)
            j = j.L
    uncover(c) 

choose_column的实现,可以选择根节点h的右节点,也可遍历选择右节点中最少的一个:

choose_column():
    j=h.R
    s=while j!=h:
        if s>j.S:
            c=j
            s=j.S
    return c

coveruncover操作分别从矩阵中移除或恢复列c以及相关的行,以及这些行对应的列

cover(c):
    c.R.L = c.L
    c.L.R = c.R
    i=c.D
    while i!=c:
        j=i.R
        while j!=i:
            j.D.U = j.U
            j.U.D = j.D
            j.C.S--
            j = j.R
        i=i.D
# 注意uncover其实就是cover的逆转操作
uncover(c):
    i=c.U
    while i!=c:
        j=i.L
        while j!=i:
            j.C.S++
            j.D.U = j
            j.U.D = j
            j=j.L
        i=i.U
    c.R.L=c
    c.L.R=c

我们使用一个boolean函数w构造矩阵:

init(n,m,w):
    h = new Node()
    leftHead = NIL
    left = NIL
    # 按行遍历,需要记录每一列的最近up
    up = new Node[m]
    for i=0 to n:
       for j=0 to m:
           if w(i,j)==0:
               continue
           t = new Node()
           # 更新最近的左节点
           if leftHead == NIL:
               left = leftHead = t
           left.R = t
           t.L = left
           left = t
           # 更新最近的头部
           if up[j] == NIL:
               up[j].C = up[j] = t
           up[j].D = t
           t.U = up[j]
           t.C = up[j].C
           up[j]=t
       if leftHead != NIL:
           if i==0:
               h.R = leftHead
               h.L = left
           leftHead.L = left
           left.R = leftHead
       leftHead = left = NIL

数独问题转化为精确覆盖问题:列是约束条件,数独游戏共有4类约束条件
1.每个位置只能放一个数字
2.每一行必须放置满所有的数字
3.每一列必须放置满所有的数字
4.每个3*3的区域必须放置满所有的数字
这些约束使用列约束表示就是:
1.第i个位置放置一个数,共有9*9个位置,所以有9*9列
2.第i行出现j 9行,9个数字,共9*9列
3.第i列出现j,9列,9个数字,共9*9列
4.第i个区域出现j,9个区域,9个数字,共9*9列
所以总共有9*9*4=324列

行是解的状态,即第i行j列放置k,共9行,9列,9个数字,所以总共9*9*9行,即729行。

初始化:从给定的数独状态中,已经有一些固定的行状态为true,所以分别将4个约束条件对应的列置1。然后构造矩阵。
寻找解:将矩阵中所有已经确定的解移除,剩余一个尚未解决的矩阵;目标是移除所有的列,最终获得解,因此,对每一行,尝试将其加入解;然后递归解决这个问题即可。

建议:使用dancing links解决过于复杂,但是该思想值得借鉴。

5.贪吃蛇

题目:拼图游戏

字符串

1.KMP查找算法

复杂度O(N+P), N是字符串长度,P是模式长度

2.模式匹配

3.简单正则表达式

12.字符串交换的最大连续字符数

问题:字符串S由小写字母构成,长度为n。定义一种操作,每次都可以挑选字符串中任意的两个相邻字母进行交换。询问在至多交换m次之后,字符串中最多有多少个连续的位置上的字母相同?

:我们知道最终的最大连续的字符肯定是26个小写字母中的一个,我们分别求出26个字母的最大值进行比较即可。
现在来看求字符v的最大值,显然字符串中v可以分为n个区间,每个区间包含长度信息len以及区间之间的间隔d。
由于需要连续字符,所以显然将所有字符往某个位置移动是最好的策略,因为如果不这样,一部分字符往a处移动,另一部分往b出移动,两个策略产生的值一定小于全部往a处移动或全部往b处移动。因为将一部分a移动到b总能产生比b更大的连续数,将一部分b往a移动也总能产生比a更大的连续数。

可以采用遍历的方法求得最大值。

5.判断两个字符串是否具有相同的组成

由于字符串的元素的个数有限,可以统计每个字符出现的次数,然后比较即可。

6.简单模式字符串匹配问题

问题:模式是包含.和*的字符串,待匹配的字符串不包含* ,判断模式与字符串是否匹配

f(i,j)表示模式[i,m-1]与字符串[j,n-1]匹配的问题。

如果模式P[i]不是.*,c*的模式,则问题转化为 f(i+1,j+1)
如果模式P[i]是.*, 则问题转化为 f(i+1,j) 和 f(i+1,j+2), 如果模式是c*,则问题转化为 f(i+1,j+2), 以及当A[i]=c时, f(i+1,j)

二位坐标

1.包围点

题目:给定二维平面上的点,寻找所有的包围点。包围点即该点的右上方没有其他的点(右上方是指 (x,y)均大于该点的范围)。
在这里插入图片描述

解法:我们考察包围点的性质,发现最左侧的包围点,一定是y最大的;第二个包围点,一定是除了第一个包围点之外最大的。
因此,首先将点集按y降序,x升序排序;使用xmin记录当前最左侧的点,然后依次验证每一个点:如果x<xmin,则该点不是解;否则,该点是解,加入解集,同时更新xmin=x

2.矩形覆盖问题

问题1391. Perfect Rectangle

给定n个矩形,判断这些矩形是否精确地构成一个完整的矩形,其中不存在重叠和遗漏

:对矩形按左下标排序,当左下标x相同时,按y排序;如果存在两个相同的左下标,则不能构成完美矩形。将排序结果加入到优先级队列Q中,当每次取出Q头部x坐标相同的,判断它们的左边是否覆盖完整矩形的左边。然后,将其中宽度最小的矩形用于切割其他矩形,将剩下的矩形重新加入队列中。

给定n个矩形,判断重叠最多的点(边界不算重叠)。

矩形的基本操作

判断给定的两个坐标是否是一个有效的矩形

只需要第一个下标严格地位于第二个下标的左下角

isValidRectange(x0,y0,x1,y1):
    return x0<x1 && y0<y1
求重叠部分

求出交叉部分,判断是否有效

findCover(x00,y00,x01,y01,x10,y10,x11,y11):
    xm0 = max(x00,x10)
    ym0 = max(y00,y10)
    xm1 = min(x01,x11)
    ym1 = min(y01,y11)
    if isValid(xm0,ym0,xm1,ym1):
        return (x0=xm0,y0=ym0,x1=xm1,y1=ym1)
    return NIL
切割

首先,我们考虑被矩形内部的一个部分切割的情况

// 注意cover必须完全在矩形内
// cover最多将矩形分成4个部分
// .................
// .               .
// .    .....      .
// .    .   .      .
// .    .....      .
// .               .
// .................
cutByCover(x0,y0,x1,y1,cover):
    subRectangles = {
        (x0,y0,cover.x0,y1),
        (cover.x0,y0,x1,cover.y1),
        (cover.x0,cover.y1,x1,y1),
        (cover.x1,y0,x1,cover.y1) 
    }
    for e in subRectangles:
        if not isValidRectange(e):
            remove e from subRectangles
    return subRectangles

然后两个矩形的切割,就是两个矩形与cover部分的切割:

cutRectange(x00,y00,x01,y01,x10,y10,x11,y11):
    cover = findCover(x00,y00,x01,y01,x10,y10,x11,y11)
    if cover == NIL:
        return [(x00,y00,x01,y01),(x10,y10,x11,y11)]
    return cutByCover(x00,y00,x01,y01,cover) + cutByCover(y01,x10,y10,x11,y11,cover)

排列组合

1.把数组排成最小的数

问题:一个正整数数组A,将其所有数字拼接成一个数,求拼接的最小值

:这个拼成的数据最终的位数是固定的,我们考虑最高位,一定是最高位最小的数字填充。但是最高位最小的数字长度不一,会限制第二个高位。我们可以证明一定是选择顺序最低的那位。对于长度相同的数字显然是,对于长度较短的数字,则优先选择长度较短的那个。因为次高位可填的数字必然头部必然还是最低的。

因此最终的方案就是讲数组转换成字符串数组,比较函数是:nm和mn谁更小。

2.第k个排列组合

问题:每个单词都包含n个’a’和m个’z’, 并且所有单词按照字典序排列,找出第k个单词是什么。

链接https://www.nowcoder.com/practice/12b1b8ef17e1441f86f322b250bff4c0?tpId=98&tqId=32838&tPage=1&rp=1&ru=/ta/2019test&qru=/ta/2019test/question-ranking

:一般性的问题,给定一个字符集合,求这些字符排列的第k字典序的值。

假定我们已经确定了某个排列A,如果两个字典序的前缀相同,则顺序取决于后缀的顺序。因此,设A的下一个排列是A’, 设它们的最长公共前缀是T, 后缀是F和F’,则显然F’是F的下一个排列。

因此,A’等于A的所有后缀中,能够产生最相邻排列中的最短的那个。只要一个排列还没有达到逆序,则它就能产生下一个排列。

next(A) = for s = A的长度为2到n的后缀,如果next(s)存在,则返回 (A-s) + next(s)

由于next(s)同样会遍历后缀,因此最终的结果就是寻找最短后缀中,尚未达到逆序的那个排列,因为该后缀最短,所以处理前面的两个数字是递增的,其他数字都是递减的。
也就是具有下面的结构:

         . i-1(逆序的点)
      .     .
   .i        .i+1
               .
                   . 
                        .i+2
                            . 
                                .
                                    .i+3                                 

交换A[i]与[i+1,n-1]中大于A[i]的第一个元素,从而第i位递增,但是此时[i+1,n-1]并不处于最小的状态,因为最小的状态是单调递增。所以,将数组后缀元素逆序,就构成了单调递增状态。

next(A):
    n=A.length
    i=n-2
    while i>=0 && A[i] >= A[i+1]:
        --i
    if i<0:
        return NIL
    # 交换 A[i] 与 A[i]右侧的第一个大于A[i]的元素
    # 使用二分查找,查找右侧第一个比,因为右侧是排序的
    # a b c d
    # c b a d
    # d b c a
    # c和d进行比较,当c!=d时,选择较小的一个,也就是最后一个大于a的元素
    # 当c==d时,显然交换c,也就是相同元素最左侧的那个
    j = binary-search-with-min-bigger(A,A[i],i+1,n-1)
    exchange A[i],A[j]
    reverse(A,i+1,n-1)
    return A
# 返回处的元素必须满足: A[r]>m, 且A[r-1] > A[r]
binary-search-with-min-bigger(A,m,i,j):
    while i<j:
        # r必须是中间偏右侧的位置,因为后面还需要进行逆序操作
        r=(i+j+1)/2
        if A[r] <= m:
            j = r - 1
        else:
            i = r
    return i
reverse(A,i,j):
   while i<j:
       int tmp=A[i]
       A[i++]=A[j]
       A[j--]tmp

算法总结:每一步操作O(N),如果需要查找第K个,需要O(KN)

解2:由于题目中只有’a’,'z’两种数字,所以考虑f(i,j)为包含i个’a’和j个’z’的的问题,可以划分为子问题f(i-1,j)和f(i,j-1)。

dp = new int[n+1][m+1]

f(i,j,m):
    d = g(i-1,j)
    if d==m:
        return 'a' + 'z'*j + 'a'*(i-1)
    elif d>m:
        return 'a' + f(i-1,j,m)
    h = g(i,j-1)
    if h + d == m:
        return 'z'*j + 'a'*i
    elif h + d > m:
        return 'z' + f(i,j-1,d-m)
    return NIL
    
g(i,j):
    if i<=0 && j<=0:
        return 0
    elif i==0||j==0:
        return 1
    if dp[i][j]!=-1:
        return dp[i][j]
    return dp[i][j] = g(i-1,j) + g(i,j-1)

解3:由于问题中只包含两个字母,因此排列的问题转化成组合的问题,其本质就是求C(n+m, m)

3.所有排列

如果不要求排列有序,实际上我们可以使用下面的递归方程获得所有排列:

f(S,i) = “a” + f( S - {a}, i+1)
其中a是属于S中的任意元素,且子递归中,a的数量应当减一;i表示排列长度

数字

1.最大的三个数乘积

题目:给定一个无序数组,包含正数、负数和0,要求从中找出3个数的乘积,使得乘积最大,要求时间复杂度:O(n),空间复杂度:O(1)

链接:https://www.nowcoder.com/practice/5f29c72b1ae14d92b9c3fa03a037ac5f?tpId=90&tqId=30776&tPage=1&rp=1&ru=/ta/2018test&qru=/ta/2018test/question-ranking

解的分析:

// 没有正数时,选择最大的三个数相乘
// 含有1个正数时,选择正数和最小的两个数相乘
// 含有2个正数时,如果只有3个元素, 则三个数相乘即可;否则,选择较大的正数和 非正数中绝对值最大的其他两个数相乘
// 含有3个以上正数时, 选择下列元素中最大的值:
//    三个最大的正数
//    最大的正数 和  非正数中绝对值最大的两个数

其中,绝对值大的两个非负数,其实就是最小的两个数(当数组中非负数含有至少2个时)。
因此,最终的结果只与5个数有关:最大的3个数和最小的2个数。

2.排列的数字是否被3整除

题目:数列按f(i) = f(i-1) + string(i) 的方式展开: 1, 12, 123,…12345678910,1234567891011…,给定i,j,试计算i到j之间能够被3整除的数的个数。

链接:https://www.nowcoder.com/practice/51dcb4eef6004f6f8f44d927463ad5e8?tpId=98&tqId=32825&tPage=1&rp=1&ru=/ta/2019test&qru=/ta/2019test/question-ranking

:我们知道能够被3整除的数,其各位数字之和一定能够被3整除。因此,问题的关键就是计算第i到j位,每一个数字的数字和与3的模。

实际上,i的各位之和与3的模,本身就等于i与3的模。这是因为数字每递增1,它的各位之和与3的模也保持递增1。

而且,这个模是存在周期的:
1 2 0 1 2 0 … 1 2 0

令k=i%3,k可取0,1,2

所以实际上 f(i) = i%3

也就是说,数列中的第i项与3的模,等于i与3的模k,加上小于k大于0的所有数字和的模。因此,如果i%3==0, 则f(i)=0, 如果i%3==1,则f(i)=1;如果i%3==2,则f(i)=0.
因此,仅当i%3==1时不能被整除,其他情况下都可以。

对于区间i,j,考察i和j所处的位置,j-i+1表示[i,j]的数字总数,令k=j-i+1 / 3, r=j-i+1%3, s=r这一部分的能被3整除的数目
则k表示其中不能被3整除的数目,r则表示余下的数字,如果j%i==1,则s=r-1;如果j%i==0,则s=r;如果j%i==2,则s=r-1

 public static int count(int m,int n){
        int k = (n-m+1)/3;
        int h = (n-m+1)%3;
        int s = 0 ;
        if(h>0){
            int w = n%3;
            if(w==0){
                s += h;
            }else if(w==2){
                s += 1;
            }
        }
        return 2*k + s;
}

关键点:模3的数字和等于该数模3.

3.乘方幂的第K项

题目 链接

已知一个正整数n,(3 <= n <= 15),将所有n的乘方幂以及所有n的乘方幂(有限个且互不相等)之和组成一个递增序列。例如,当n为4时,该序列为:1, 4, 5, 16, 17, 20, 21……
(40, 41, 40+41, 42, 40+42, 41+42, 40+41+42……)
请求出该序列的第K项


实际上,我们构建出一个三角形:

0
1 1,0
2 2,0 2,1 2,1,0
3 3,0 3,1 3,1,0 3,2 … 3,2,1,0

我们发现,每一层都是前面所有层的相加。

实际上,这个排列中,第K个数等于K所在的高度的起始值以及差值。
设h(K)为其高度, d(K)为差值,则
f(K) = nh(K) + f(d(K)), 显然最终f(K) = ni0 + ni1 + … + nik
而ik就是K的二进制表示中所有为1的位,因为h(K)就是K的最高位,h(d(K))就是次高位,依次下去。

从另外一个角度看,我们可以考虑特殊的2进制数: A0*n0+A1*n1 + A2*n2 + … + Ai*ni,其中Ai==0或1,显然A0 A1 A2 … Ai组合成的二进制数就是该二进制数空间的顺序,因此只需要将K表示为二进制数,然后赋予A0…Ai,即可求得f(K).

cur = n^0
c = 0
while K>0:
    if K & 0b1 == 1:
        c += cur
     cur *= n
     K >>>= 1
return c

4.丑数

题目1201 Ugly Number III, 264 Ugly Number II, 313 Super Ugly Number

一个丑数是仅能被a,b或c整除的数,给定a,b,c, 和n,求第n个丑数

:丑数能够用表示为 ax * by * cz
考虑我们已知前k个丑数,如何生成第k+1个丑数?

我们使用队列Q表示所有已生成的k个丑数,则第k+1个丑数必然是a,b或者c的乘积,如果Sk+1 = Sk * a,则Sk = Sk+1/a必然属于已经生成的丑数,同理对Sk+1/b,Sk+1/c也是如此。
所以,第k+1个丑数必然是队列Q中的某一个丑数与a,b或c的乘积,且是大于已知最大丑数Sk的最小值。

所以我们只需要将队列的值从小到大依次与a,b,c相乘并取满足大于丑数Sk的最小值即可。

for e in Q:
    s = min(e*a,e*b,e*c)
    if s>Sk:
        Q.add(s)
        break

优化:我们注意到求第k和第k+1个丑数时,都需要对a*e进行重复计算并求最小值,而这个最小值实际上就是上一个元素,因此,我们可以记录上次计算最小值的最后元素,后面循环时继续使用

i=1,j=1,k=1
s = min(Q[i]*a, Q[j]*b,Q[k]*c)
if s == Q[i]*a:
    ++i
elif s== Q[j]*a:
    ++j
else:
    ++k

上面的代码使用i,j,k来保存a,b,c最后一次相乘能够产生大于序列Q中所有元素下标。
循环不变式:Q[j],Q[j],Q[k]是与a,b,c相乘产生的结果的不在队列中的最小值,这是它们能够产生队列下一个元素的关键条件。

生成序列问题

所谓生成序列问题,是指在给定一个现有队列Q,一个候选元素集合C,选择Q与C中的两个元素生成下一个Q中的元素。每次生成元素时,C与Q会生成一个候选集合S,选择S中的最优元素加入Q即得下一个元素。

实际上,丑数问题的候选元素集合C={a,b,c}与队列Q的生成值可以刻画为3个有序数组:

[0]:  a*e1,a*e2,a*e3,...
[1]:  b*e1,b*e2,b*e3,...
[2]:  c*e1,c*e2,c*e3,...

我们知道S中的最小元素就是[0],[1],[2]中的最小元素,并且要求该元素未被加入到集合中。

所以,实际上整个生成过程就是对S进行3路归并排序的过程。

上面讨论的生成序列问题都是在候选元素C与队列中Q中的每一个元素结合都是公平的,也就是说S是一个矩阵

下面的问题时,给定一个正整数n, 表示n^0, n^1, n^0 + n^1, ...的序列,我们分析发现S在每次生成时并不都是公平的,因为对每个元素ei,要排除ei中已经出现过的元素,因此不能使用上面的序列生成算法。

Q = e1 e2 e3 
C = { n^0 n^1 n^2 ... n^k}
S = 
[0]: e1+n^0      e1+n^1 ... 
[1]: e2+n^0,     e2+n^1 ...
...

超级丑数

题目313 Super Ugly Number

给定一个排序的包含k个素数的数组,寻找第n个仅由该数组元素组成的数

此问题区别于一般的丑数问题,因为一般丑数问题都保证给定丑数小于不会出现 a*b > c的情况;此外,1是第一个丑数。所以,每次遇到下一个元素和最后一个元素相同时,总是跳过这个元素。

也可以在遇到相同元素时,跳过这些元素。
主要,3个素数时,同样需要手动跳过这些元素。

class Solution {
    
   static class Node {
        int e;
        Node next;
        public Node(int e){
            this.e = e;
        }
    }
    public int nthSuperUglyNumber(int n, int[] primes) {
        Node[] its = new Node[primes.length];
        Node tail = new Node(1);        
        
        for(int i=0;i<primes.length;++i){
            its[i] = tail;
        }
        
        for(int i=1;i < n;++i){
            int minPrimeIdx = -1;
            int lastProd = -1;
            for(int j=0;j<primes.length;++j){
                int prod = primes[j]*its[j].e;
                if(lastProd==-1||prod<lastProd){
                    lastProd = prod;
                    minPrimeIdx = j;
                }
            }
            // 如果相同,则跳过这个数字,这里主要是因为冷启动阶段的问题
            if(tail.e==lastProd){
                its[minPrimeIdx] = its[minPrimeIdx].next;
                --i;
                continue;
            }
            tail.next = new Node(lastProd);
            tail = tail.next;
            its[minPrimeIdx] = its[minPrimeIdx].next;
        }
        return tail.e;
    }
}

上面的node节点也可以换成数组,使用链表的主要原因是即时回收内存,避免一次性分配过多元素。

5.平方组合

题目 279. Perfect Squares

给定一个数n,n可以被表示为多个数的平方,比如10 = 32 + 12, 求n的平方表示中最少数目

本题是普通的动态规划: f(n) = min { f(n-X) + 1 } , X是小于n的所有平方数。
为什么不考虑 f(n) = min { f(n-i) + f(i) } 这种普通的分割呢?因为最终所有的分割都会表达成某个数的平方。

设计模式

1.抽象工厂模式

网络

1.DNS协议

数据库

中间件

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值