【题目】
实现一个特殊的栈,在实现栈的基本功能的基础上,再实现返回栈中最小元素的操作。
【要求】
1.pop、push、getMin操作的时间复杂度都是O (1)。
2.设计的栈类型可以使用现成的栈结构。
【难度】
士 ★☆☆☆
【解答】
在设计上我们使用两个栈,一个栈用来保存当前栈中的元素,其功能和一个正常的栈没有区别,这个栈记为stackData;另一个栈用于保存每一步的最小值,这个栈记为stackMin。具体的实现方式有两种。
第一种设计方案如下。
● 压入数据规则
假设当前数据为newNum,先将其压入stackData。然后判断stackMin是否为空:
● 如果为空,则newNum也压入stackMin。
● 如果不为空,则比较newNum和stackMin的栈顶元素中哪一个更小:
● 如果newNum更小或两者相等,则newNum也压入stackMin;
● 如果stackMin中栈顶元素小,则stackMin不压入任何内容。
举例:依次压入3、4、5、1、2、1的过程中,stockData和stackMin的变化如图1-1所示。
图1-1
● 弹出数据规则
先在stackData中弹出栈顶元素,记为value。然后比较当前stackMin的栈顶元素和value哪一个更小。
通过上文提到的压入规则可知,stackMin中存在的元素是从栈底到栈顶逐渐变小的,stackMin栈顶的元素既是stackMin栈的最小值,也是当前stackData栈的最小值。所以不会出现value比stackMin的栈顶元素更小的情况,value只可能大于或等于stackMin的栈顶元素。
当value等于stackMin的栈顶元素时,stackMin弹出栈顶元素;当value大于stackMin的栈顶元素时,stackMin不弹出栈顶元素;返回value。
很明显可以看出,压入与弹出规则是对应的。
● 查询当前栈中的最小值操作
由上文的压入数据规则和弹出数据规则可知,stackMin始终记录着stackData中的最小值,所以,stackMin的栈顶元素始终是当前stackData中的最小值。
方案一的代码实现如MyStack1类所示:
public class MyStack1 {
private Stack<Integer> stackData;
private Stack<Integer> stackMin;
public MyStack1() {
this.stackData = new Stack<Integer>();
this.stackMin = new Stack<Integer>();
}
public void push(int newNum) {
if (this.stackMin.isEmpty()) {
this.stackMin.push(newNum);
} else if (newNum <= this.getmin()) {
this.stackMin.push(newNum);
}
this.stackData.push(newNum);
}
public int pop() {
if (this.stackData.isEmpty()) {
throw new RuntimeException("Your stack is empty.");
}
int value = this.stackData.pop();
if (value == this.getmin()) {
this.stackMin.pop();
}
return value;
}
public int getmin() {
if (this.stackMin.isEmpty()) {
throw new RuntimeException("Your stack is empty.");
}
return this.stackMin.peek();
}
}
第二种设计方案如下。
● 压入数据规则
假设当前数据为newNum,先将其压入stackData。然后判断stackMin是否为空。
如果为空,则newNum也压入stackMin;如果不为空,则比较newNum和stackMin的栈顶元素中哪一个更小:
如果newNum更小或两者相等,则newNum也压入stackMin;如果stackMin中栈顶元素小,则把stackMin的栈顶元素重复压入stackMin,即在栈顶元素上再压入一个栈顶元素。
举例:依次压入3、4、5、1、2、1的过程中,stockData和stackMin的变化如图1-2所示。
图1-2
● 弹出数据规则
在stackData中弹出数据,弹出的数据记为value;弹出stackMin中的栈顶;返回value。
很明显可以看出,压入与弹出规则是对应的。
● 查询当前栈中的最小值操作
由上文的压入数据规则和弹出数据规则可知,stackMin始终记录着stackData中的最小值,所以stackMin的栈顶元素始终是当前stackData中的最小值。
方案二的代码实现如MyStack2类所示:
public class MyStack2 {
private Stack<Integer> stackData;
private Stack<Integer> stackMin;
public MyStack2() {
this.stackData = new Stack<Integer>();
this.stackMin = new Stack<Integer>();
}
public void push(int newNum) {
if (this.stackMin.isEmpty()) {
this.stackMin.push(newNum);
} else if (newNum < this.getmin()) {
this.stackMin.push(newNum);
} else {
int newMin = this.stackMin.peek();
this.stackMin.push(newMin);
}
this.stackData.push(newNum);
}
public int pop() {
if (this.stackData.isEmpty()) {
throw new RuntimeException("Your stack is empty.");
}
this.stackMin.pop();
return this.stackData.pop();
}
public int getmin() {
if (this.stackMin.isEmpty()) {
throw new RuntimeException("Your stack is empty.");
}
return this.stackMin.peek();
}
}
【点评】
方案一和方案二其实都是用stackMin栈保存着stackData每一步的最小值。共同点是所有操作的时间复杂度都为O (1)、空间复杂度都为O (n )。区别是:方案一中stackMin压入时稍省空间,但是弹出操作稍费时间;方案二中stackMin压入时稍费空间,但是弹出操作稍省时间。
【题目】
编写一个类,用两个栈实现队列,支持队列的基本操作(add、poll、peek)。
【难度】
尉 ★★☆☆
【解答】
栈的特点是先进后出,而队列的特点是先进先出。我们用两个栈正好能把顺序反过来实现类似队列的操作。
具体实现上是一个栈作为压入栈,在压入数据时只往这个栈中压入,记为stackPush;另一个栈只作为弹出栈,在弹出数据时只从这个栈弹出,记为stackPop。
因为数据压入栈的时候,顺序是先进后出的。那么只要把stackPush的数据再压入stackPop中,顺序就变回来了。例如,将1~5依次压入stackPush,那么从stackPush的栈顶到栈底为5~1,此时依次再将5~1倒入stackPop,那么从stackPop的栈顶到栈底就变成了1~5。再从stackPop弹出时,顺序就像队列一样,如图1-3所示。
图1-3
听起来虽然简单,实际上必须做到以下两点。
1.如果stackPush要往stackPop中压入数据,那么必须一次性把stackPush中的数据全部压入。
2.如果stackPop不为空,stackPush绝对不能向stackPop中压入数据。
违反了以上两点都会发生错误。
违反1的情况举例:1~5依次压入stackPush,stackPush的栈顶到栈底为5~1,从stackPush压入stackPop时,只将5和4压入了stackPop,stackPush还剩下1、2、3没有压入。此时如果用户想进行弹出操作,那么4将最先弹出,与预想的队列顺序就不一致。
违反2的情况举例:1~5依次压入stackPush,stackPush将所有的数据压入了stackPop,此时从stackPop的栈顶到栈底就变成了1~5。此时又有6~10依次压入stackPush,stackPop不为空,stackPush不能向其中压入数据。如果违反2压入了stackPop,从stackPop的栈顶到栈底就变成了6~10、1~5。那么此时如果用户想进行弹出操作,6将最先弹出,与预想的队列顺序就不一致。
上面介绍了压入数据的注意事项。那么这个压入数据的操作在何时发生呢?
这个选择的时机可以有很多,调用add、poll和peek三种方法中的任何一种时发生“压”入数据的行为都是可以的。只要满足如上提到的两点,就不会出错。
本书的实现是在调用poll和peek方法时进行压入数据的过程。
具体实现请参看如下的TwoStacksQueue类:
public class TwoStacksQueue {
public Stack<Integer> stackPush;
public Stack<Integer> stackPop;
public TwoStacksQueue() {
stackPush = new Stack<Integer>();
stackPop = new Stack<Integer>();
}
public void add(int pushInt) {
stackPush.push(pushInt);
}
public int poll() {
if (stackPop.empty() && stackPush.empty()) {
throw new RuntimeException("Queue is empty! ");
} else if (stackPop.empty()) {
while (! stackPush.empty()) {
stackPop.push(stackPush.pop());
}
}
return stackPop.pop();
}
public int peek() {
if (stackPop.empty() && stackPush.empty()) {
throw new RuntimeException("Queue is empty! ");
} else if (stackPop.empty()) {
while (! stackPush.empty()) {
stackPop.push(stackPush.pop());
}
}
return stackPop.peek();
}
}
【题目】
一个栈依次压入1、2、3、4、5,那么从栈顶到栈底分别为5、4、3、2、1。将这个栈转置后,从栈顶到栈底为1、2、3、4、5,也就是实现栈中元素的逆序,但是只能用递归函数来实现,不能用其他数据结构。
【难度】
尉 ★★☆☆
【解答】
本题考查栈的操作和递归函数的设计,我们需要设计出两个递归函数。
递归函数一:将栈stack的栈底元素返回并移除。
具体过程就是如下代码中的getAndRemoveLastElement方法。
public static int getAndRemoveLastElement(Stack<Integer> stack) {
int result = stack.pop();
if (stack.isEmpty()) {
return result;
} else {
int last = getAndRemoveLastElement(stack);
stack.push(result);
return last;
}
}
如果从stack的栈顶到栈底依次为3、2、1,这个函数的具体过程如图1-4所示。
图1-4
递归函数二:逆序一个栈,就是题目要求实现的方法,具体过程就是如下代码中的reverse方法。该方法使用了上面提到的getAndRemoveLastElement方法。
public static void reverse(Stack<Integer> stack) {
if (stack.isEmpty()) {
return;
}
int i = getAndRemoveLastElement(stack);
reverse(stack);
stack.push(i);
}
如果从stack的栈顶到栈底依次为3、2、1,reverse函数的具体过程如图1-5所示。
图1-5
getAndRemoveLastElement方法在图中简单表示为get方法,表示移除并返回当前栈底元素。
【题目】
宠物、狗和猫的类如下:
public class Pet {
private String type;
public Pet(String type) {
this.type = type;
}
public String getPetType() {
return this.type;
}
}
public class Dog extends Pet {
public Dog() {
super("dog");
}
}
public class Cat extends Pet {
public Cat() {
super("cat");
}
}
实现一种狗猫队列的结构,要求如下:
● 用户可以调用add方法将cat类或dog类的实例放入队列中;
● 用户可以调用pollAll方法,将队列中所有的实例按照进队列的先后顺序依次弹出;
● 用户可以调用pollDog方法,将队列中dog类的实例按照进队列的先后顺序依次弹出;
● 用户可以调用pollCat方法,将队列中cat类的实例按照进队列的先后顺序依次弹出;
● 用户可以调用isEmpty方法,检查队列中是否还有dog或cat的实例;
● 用户可以调用isDogEmpty方法,检查队列中是否有dog类的实例;
● 用户可以调用isCatEmpty方法,检查队列中是否有cat类的实例。
【难度】
士 ★☆☆☆
【解答】
本题考查实现特殊数据结构的能力以及针对特殊功能的算法设计能力。
本题为开放类型的面试题,希望读者能有自己的实现,在这里列出几种常见的设计错误:
● cat队列只放cat实例,dog队列只放dog实例,再用一个总队列放所有的实例。
错误原因:cat、dog以及总队列的更新问题。
● 用哈希表,key表示一个cat实例或dog实例,value表示这个实例进队列的次序。
错误原因:不能支持一个实例多次进队列的功能需求,因为哈希表的key只能对应一个value值。
● 将用户原有的cat或dog类改写,加一个计数项来表示某一个实例进队列的时间。
错误原因:不能擅自改变用户的类结构。
本题实现将不同的实例盖上时间戳的方法,但是又不能改变用户本身的类,所以定义一个新的类,具体实现请参看如下的PetEnterQueue类。
public class PetEnterQueue {
private Pet pet;
private long count;
public PetEnterQueue(Pet pet, long count) {
this.pet = pet;
this.count = count;
}
public Pet getPet() {
return this.pet;
}
public long getCount() {
return this.count;
}
public String getEnterPetType() {
return this.pet.getPetType();
}
}
PetEnterQueue类在构造时,pet是用户原有的实例,count就是这个实例的时间戳。
我们实现的队列其实是PetEnterQueue类的实例。大体说来,首先有一个不断累加的数据项,用来表示实例进队列的时间;同时有两个队列,一个是只放dog类实例的队列dogQ,另一个是只放cat类实例的队列catQ。
在加入实例时,如果实例是dog,就盖上时间戳,生成对应的PetEnterQueue类的实例,然后放入dogQ;如果实例是cat,就盖上时间戳,生成对应的PetEnterQueue类的实例,然后放入catQ。具体过程请参看如下DogCatQueue类的add方法。
只想弹出dog类的实例时,从dogQ里不断弹出即可,具体过程请参看如下DogCatQueue类的pollDog方法。
只想弹出cat类的实例时,从catQ里不断弹出即可,具体过程请参看如下DogCatQueue类的pollCat方法。
想按实际顺序弹出实例时,因为dogQ的队列头表示所有dog实例中最早进队列的实例,同时catQ的队列头表示所有的cat实例中最早进队列的实例。则比较这两个队列头的时间戳,谁更早,就弹出谁。具体过程请参看如下DogCatQueue类的pollAll方法。
DogCatQueue类的整体代码如下:
public class DogCatQueue {
private Queue<PetEnterQueue> dogQ;
private Queue<PetEnterQueue> catQ;
private long count;
public DogCatQueue() {
this.dogQ = new LinkedList<PetEnterQueue>();
this.catQ = new LinkedList<PetEnterQueue>();
this.count = 0;
}
public void add(Pet pet) {
if (pet.getPetType().equals("dog")) {
this.dogQ.add(new PetEnterQueue(pet, this.count++));
} else if (pet.getPetType().equals("cat")) {
this.catQ.add(new PetEnterQueue(pet, this.count++));
} else {
throw new RuntimeException("err, not dog or cat");
}
}
public Pet pollAll() {
if (! this.dogQ.isEmpty() && ! this.catQ.isEmpty()) {
if(this.dogQ.peek().getCount() < this.catQ.peek().Get
Count()) {
return this.dogQ.poll().getPet();
} else {
return this.catQ.poll().getPet();
}
} else if (! this.dogQ.isEmpty()) {
return this.dogQ.poll().getPet();
} else if (! this.catQ.isEmpty()) {
return this.catQ.poll().getPet();
} else {
throw new RuntimeException("err, queue is empty! ");
}
}
public Dog pollDog() {
if (! this.isDogQueueEmpty()) {
return (Dog) this.dogQ.poll().getPet();
} else {
throw new RuntimeException("Dog queue is empty! ");
}
}
public Cat pollCat() {
if (! this.isCatQueueEmpty()) {
return (Cat) this.catQ.poll().getPet();
} else
throw new RuntimeException("Cat queue is empty! ");
}
public boolean isEmpty() {
return this.dogQ.isEmpty() && this.catQ.isEmpty();
}
public boolean isDogQueueEmpty() {
return this.dogQ.isEmpty();
}
public boolean isCatQueueEmpty() {
return this.catQ.isEmpty();
}
}
【题目】
一个栈中元素的类型为整型,现在想将该栈从顶到底按从大到小的顺序排序,只许申请一个栈。除此之外,可以申请新的变量,但不能申请额外的数据结构。如何完成排序?
【难度】
士 ★☆☆☆
【解答】
将要排序的栈记为stack,申请的辅助栈记为help。在stack上执行pop操作,弹出的元素记为cur。
● 如果cur小于或等于help的栈顶元素,则将cur直接压入help;
● 如果cur大于help的栈顶元素,则将help的元素逐一弹出,逐一压入stack,直到cur小于或等于help的栈顶元素,再将cur压入help。
一直执行以上操作,直到stack中的全部元素都压入到help。最后将help中的所有元素逐一压入stack,即完成排序。
public static void sortStackByStack(Stack<Integer> stack) {
Stack<Integer> help = new Stack<Integer>();
while (! stack.isEmpty()) {
int cur = stack.pop();
while (! help.isEmpty() && help.peek() > cur) {
stack.push(help.pop());
}
help.push(cur);
}
while (! help.isEmpty()) {
stack.push(help.pop());
}
}
【题目】
汉诺塔问题比较经典,这里修改一下游戏规则:现在限制不能从最左侧的塔直接移动到最右侧,也不能从最右侧直接移动到最左侧,而是必须经过中间。求当塔有N 层的时候,打印最优移动过程和最优移动总步数。
例如,当塔数为两层时,最上层的塔记为1,最下层的塔记为2,则打印:
Move 1 from left to mid
Move 1 from mid to right
Move 2 from left to mid
Move 1 from right to mid
Move 1 from mid to left
Move 2 from mid to right
Move 1 from left to mid
Move 1 from mid to right
It will move 8 steps.
注意:关于汉诺塔游戏的更多讨论,将在本书递归与动态规划的章节中继续。
【要求】
用以下两种方法解决。
● 方法一:递归的方法;
● 方法二:非递归的方法,用栈来模拟汉诺塔的三个塔。
【难度】
校 ★★★☆
【解答】
方法一:递归的方法。
首先,如果只剩最上层的塔需要移动,则有如下处理:
1.如果希望从“左”移到“中”,打印“Move 1 from left to mid”。
2.如果希望从“中”移到“左”,打印“Move 1 from mid to left”。
3.如果希望从“中”移到“右”,打印“Move 1 from mid to right”。
4.如果希望从“右”移到“中”,打印“Move 1 from right to mid”。
5.如果希望从“左”移到“右”,打印“Move 1 from left to mid”和“Move 1 from mid to right”。
6.如果希望从“右”移到“左”,打印“Move 1 from right to mid”和“Move 1 from mid to left”。
以上过程就是递归的终止条件,也就是只剩上层塔时的打印过程。
接下来,我们分析剩下多层塔的情况。
如果剩下N 层塔,从最上到最下依次为1~N ,则有如下判断:
1.如果剩下的N 层塔都在“左”,希望全部移到“中”,则有三个步骤。
1)将1~N -1层塔先全部从“左”移到“右”,明显交给递归过程。
2)将第N 层塔从“左”移到“中”。
3)再将1~N -1层塔全部从“右”移到“中”,明显交给递归过程。
2.如果把剩下的N 层塔从“中”移到“左”,从“中”移到“右”,从“右”移到“中”,过程与情况1同理,一样是分解为三步,在此不再详述。
3.如果剩下的N 层塔都在“左”,希望全部移到“右”,则有五个步骤。
1)将1~N -1层塔先全部从“左”移到“右”,明显交给递归过程。
2)将第N 层塔从“左”移到“中”。
3)将1~N -1层塔全部从“右”移到“左”,明显交给递归过程。
4)将第N 层塔从“中”移到“右”。
5)最后将1~N -1层塔全部从“左”移到“右”,明显交给递归过程。
4.如果剩下的N 层塔都在“右”,希望全部移到“左”,过程与情况3同理,一样是分解为五步,在此不再详述。
以上递归过程经过逻辑化简之后的代码请参看如下代码中的hanoiProblem1方法。
public int hanoiProblem1(int num, String left, String mid,
String right) {
if (num < 1) {
return 0;
}
return process(num, left, mid, right, left, right);
}
public int process(int num, String left, String mid, String right,
String from, String to) {
if (num == 1) {
if (from.equals(mid) || to.equals(mid)) {
System.out.println("Move 1 from " + from + " to " + to);
return 1;
} else {
System.out.println("Move 1 from " + from + " to " + mid);
System.out.println("Move 1 from " + mid + " to " + to);
return 2;
}
}
if (from.equals(mid) || to.equals(mid)) {
String another = (from.equals(left) || to.equals(left)) ? right :
left;
int part1 = process(num - 1, left, mid, right, from, another);
int part2 = 1;
System.out.println("Move " + num + " from " + from + " to " + to);
int part3 = process(num - 1, left, mid, right, another, to);
return part1 + part2 + part3;
} else {
int part1 = process(num - 1, left, mid, right, from, to);
int part2 = 1;
System.out.println("Move " + num + " from " + from + " to " + mid);
int part3 = process(num - 1, left, mid, right, to, from);
int part4 = 1;
System.out.println("Move " + num + " from " + mid + " to " + to);
int part5 = process(num - 1, left, mid, right, from, to);
return part1 + part2 + part3 + part4 + part5;
}
}
方法二:非递归的方法——用栈来模拟整个过程。
修改后的汉诺塔问题不能让任何塔从“左”直接移动到“右”,也不能从“右”直接移动到“左”,而是要经过中间。也就是说,实际动作只有4个:“左”到“中”、“中”到“左”、“中”到“右”、“右”到“中”。
现在我们把左、中、右三个地点抽象成栈,依次记为LS、MS和RS。最初所有的塔都在LS上。那么如上4个动作就可以看作是:某一个栈(from)把栈顶元素弹出,然后压入到另一个栈里(to),作为这一个栈(to)的栈顶。
例如,如果是7层塔,在最初时所有的塔都在LS上,LS从栈顶到栈底就依次是1~7,如果现在发生了“左”到“中”的动作,这个动作对应的操作是LS栈将栈顶元素1弹出,然后1压入到MS栈中,成为MS的栈顶。其他的操作同理。
一个动作能发生的先决条件是不违反小压大的原则。
from栈弹出的元素num如果想压入到to栈中,那么num的值必须小于当前to栈的栈顶。
还有一个原则不是很明显,但也是非常重要的,叫相邻不可逆原则,解释如下:
1.我们把四个动作依次定义为:L->M、M->L、M->R和R->M。
2.很明显,L->M和M->L过程互为逆过程,M->R和R->M互为逆过程。
3.在修改后的汉诺塔游戏中,如果想走出最少步数,那么任何两个相邻的动作都不是互为逆过程的。举个例子:如果上一步的动作是L->M,那么这一步绝不可能是M->L,直观地解释为:你上一步把一个栈顶数从“左”移动到“中”,这一步为什么又要移回去呢?这必然不是取得最小步数的走法。同理,M->R动作和R->M动作也不可能相邻发生。
有了小压大和相邻不可逆原则后,可以推导出两个十分有用的结论——非递归的方法核心结论:
1.游戏的第一个动作一定是L->M,这是显而易见的。
2.在走出最少步数过程中的任何时刻,四个动作中只有一个动作不违反小压大和相邻不可逆原则,另外三个动作一定都会违反。
对于结论2,现在进行简单的证明。
因为游戏的第一个动作已经确定是L->M,则以后的每一步都会有前一步的动作。
假设前一步的动作是L->M:
1.根据小压大原则,L->M的动作不会重复发生。
2.根据相邻不可逆原则,M->L的动作也不该发生。
3.根据小压大原则,M->R和R->M只会有一个达标。
假设前一步的动作是M->L:
1.根据小压大原则,M->L的动作不会重复发生。
2.根据相邻不可逆原则,L->M的动作也不该发生。
3.根据小压大原则,M->R和R->M只会有一个达标。
假设前一步的动作是M->R:
1.根据小压大原则,M->R的动作不会重复发生。
2.根据相邻不可逆原则,R->M的动作也不该发生。
3.根据小压大原则,L->M和M->L只会有一个达标。
假设前一步的动作是R->M:
1.根据小压大原则,R->M的动作不会重复发生。
2.根据相邻不可逆原则,M->R的动作也不该发生。
3.根据小压大原则,L->M和M->L只会有一个达标。
综上所述,每一步只会有一个动作达标。那么只要每走一步都根据这两个原则考查所有的动作就可以,哪个动作达标就走哪个动作,反正每次都只有一个动作满足要求,按顺序走下来即可。
非递归的具体过程请参看如下代码中的hanoiProblem2方法。
public enum Action {
No, LToM, MToL, MToR, RToM
}
public int hanoiProblem2(int num, String left, String mid, String right) {
Stack<Integer> lS = new Stack<Integer>();
Stack<Integer> mS = new Stack<Integer>();
Stack<Integer> rS = new Stack<Integer>();
lS.push(Integer.MAX_VALUE);
mS.push(Integer.MAX_VALUE);
rS.push(Integer.MAX_VALUE);
for (int i = num; i > 0; i--) {
lS.push(i);
}
Action[] record = { Action.No };
int step = 0;
while (rS.size() ! = num + 1) {
step += fStackTotStack(record, Action.MToL, Action.LToM, lS, mS,
left, mid);
step += fStackTotStack(record, Action.LToM, Action.MToL, mS, lS,
mid, left);
step += fStackTotStack(record, Action.RToM, Action.MToR, mS, rS,
mid, right);
step += fStackTotStack(record, Action.MToR, Action.RToM, rS, mS,
right, mid);
}
return step;
}
public static int fStackTotStack(Action[] record, Action preNoAct,
Action nowAct, Stack<Integer> fStack, Stack<Integer> tStack,
String from, String to) {
if (record[0] ! = preNoAct && fStack.peek() < tStack.peek()) {
tStack.push(fStack.pop());
System.out.println("Move " + tStack.peek() + " from " + from + "
to " + to);
record[0] = nowAct;
return 1;
}
return 0;
}
【题目】
有一个整型数组arr和一个大小为w 的窗口从数组的最左边滑到最右边,窗口每次向右边滑一个位置。
例如,数组为[4,3,5,4,3,3,6,7],窗口大小为3时:
[4 3 5] 4 3 3 6 7 窗口中最大值为5
4 [3 5 4] 3 3 6 7 窗口中最大值为5
4 3 [5 4 3] 3 6 7 窗口中最大值为5
4 3 5 [4 3 3] 6 7 窗口中最大值为4
4 3 5 4 [3 3 6] 7 窗口中最大值为6
4 3 5 4 3 [3 6 7] 窗口中最大值为7
如果数组长度为n ,窗口大小为w ,则一共产生n -w +1个窗口的最大值。
请实现一个函数。
● 输入:整型数组arr,窗口大小为w 。
● 输出:一个长度为n -w +1的数组res,res[i]表示每一种窗口状态下的最大值。
以本题为例,结果应该返回{5,5,5,4,6,7}。
【难度】
尉 ★★☆☆
【解答】
如果数组长度为N ,窗口大小为w ,如果做出时间复杂度O (N ×w )的解法是不能让面试官满意的,本题要求面试者想出时间复杂度O (N )的实现。
本题的关键在于利用双端队列来实现窗口最大值的更新。首先生成双端队列qmax,qmax中存放数组arr中的下标。
假设遍历到arr[i],qmax的放入规则为:
1.如果qmax为空,直接把下标i放进qmax,放入过程结束。
2.如果qmax不为空,取出当前qmax队尾存放的下标,假设为j。
1)如果arr[j]>arr[i],直接把下标i放进qmax的队尾,放入过程结束。
2)如果arr[j]<=arr[i],把j从qmax中弹出,继续qmax的放入规则。
假设遍历到arr[i],qmax的弹出规则为:
如果qmax队头的下标等于i-w,说明当前qmax队头的下标已过期,弹出当前对头的下标即可。
根据如上的放入和弹出规则,qmax便成了一个维护窗口为w 的子数组的最大值更新的结构。下面举例说明题目给出的例子。
1.开始时qmax为空,qmax={}
2.遍历到arr[0]==4,将下标0放入qmax,qmax={0}。
3.遍历到arr[1]==3,当前qmax的队尾下标为0,又有arr[0]>arr[1],所以将下标1放入qmax的尾部,qmax={0,1}。
4.遍历到arr[2]==5,当前qmax的队尾下标为1,又有arr[1]<=arr[2],所以将下标1从qmax的尾部弹出,qmax变为{0}。当前qmax的队尾下标为0,又有arr[0]<=arr[2],所以将下标0从qmax尾部弹出,qmax变为{}。将下标2放入qmax,qmax={2}。此时已经遍历到下标2的位置,窗口arr[0..2]出现,当前qmax队头的下标为2,所以窗口arr[0..2]的最大值为arr[2](即5)。
5.遍历到arr[3]==4,当前qmax的队尾下标为2,又有arr[2]>arr[3],所以将下标3放入qmax尾部,qmax={2,3}。窗口arr[1..3]出现,当前qmax队头的下标为2,这个下标还没有过期,所以窗口arr[1..3]的最大值为arr[2](即5)。
6.遍历到arr[4]==3,当前qmax的队尾下标为3,又有arr[3]>arr[4],所以将下标4放入qmax尾部,qmax={2,3,4}。窗口arr[2..4]出现,当前qmax队头的下标为2,这个下标还没有过期,所以窗口arr[2..4]的最大值为arr[2](即5)。
7.遍历到arr[5]==3,当前qmax的队尾下标为4,又有arr[4]<=arr[5],所以将下标4从qmax的尾部弹出,qmax变为{2,3}。当前qmax的队尾下标为3,又有arr[3]>arr[5],所以将下标5放入qmax尾部,qmax={2,3,5}。窗口arr[3..5]出现,当前qmax队头的下标为2,这个下标已经过期,所以从qmax的头部弹出,qmax变为{3,5}。当前qmax队头的下标为3,这个下标没有过期,所以窗口arr[3..5]的最大值为arr[3](即4)。
8.遍历到arr[6]==6,当前qmax的队尾下标为5,又有arr[5]<=arr[6],所以将下标5从qmax的尾部弹出,qmax变为{3}。当前qmax的队尾下标为3,又有arr[3]<=arr[6],所以将下标3从qmax的尾部弹出,qmax变为{}。将下标6放入qmax,qmax={6}。窗口arr[4..6]出现,当前qmax队头的下标为6,这个下标没有过期,所以窗口arr[4..6]的最大值为arr[6] (即6)。
9.遍历到arr[7]==7,当前qmax的队尾下标为6,又有arr[6]<=arr[7],所以将下标6从qmax的尾部弹出,qmax变为{}。将下标7放入qmax,qmax={7}。窗口arr[5..7]出现,当前qmax队头的下标为7,这个下标没有过期,所以窗口arr[5..7]的最大值为arr[7] (即7)。
10.依次出现的窗口最大值为[5,5,5,4,6,7],在遍历过程中收集起来,最后返回即可。
上述过程中,每个下标值最多进qmax一次,出qmax一次。所以遍历的过程中进出双端队列的操作是时间复杂度为O (N ),整体的时间复杂度也为O (N )。具体过程参看如下代码中的getMaxWindow方法。
public int[] getMaxWindow(int[] arr, int w) {
if (arr == null || w < 1 || arr.length < w) {
return null;
}
LinkedList<Integer> qmax = new LinkedList<Integer>();
int[] res = new int[arr.length - w + 1];
int index = 0;
for (int i = 0; i < arr.length; i++) {
while (! qmax.isEmpty() && arr[qmax.peekLast()] <= arr[i]) {
qmax.pollLast();
}
qmax.addLast(i);
if (qmax.peekFirst() == i - w) {
qmax.pollFirst();
}
if (i >= w - 1) {
res[index++] = arr[qmax.peekFirst()];
}
}
return res;
}
【题目】
定义二叉树节点如下:
public class Node {
public int value;
public Node left;
public Node right;
public Node(int data) {
this.value = data;
}
}
一个数组的MaxTree定义如下。
● 数组必须没有重复元素。
● MaxTree是一棵二叉树,数组的每一个值对应一个二叉树节点。
● 包括MaxTree树在内且在其中的每一棵子树上,值最大的节点都是树的头。
给定一个没有重复元素的数组arr,写出生成这个数组的MaxTree的函数,要求如果数组长度为N ,则时间复杂度为O (N )、额外空间复杂度为O (N )。
【难度】
校 ★★★☆
【解答】
下面举例说明如何在满足时间和空间复杂度的要求下生成MaxTree。
arr = {3, 4, 5, 1, 2}
3的左边第一个比3大的数:无 3的右边第一个比3大的数:4
4的左边第一个比4大的数:无 4的右边第一个比4大的数:5
5的左边第一个比5大的数:无 5的右边第一个比5大的数:无
1的左边第一个比1大的数:5 1的右边第一个比1大的数:2
2的左边第一个比2大的数:5 2的右边第一个比2大的数:无
以下列原则来建立这棵树:
● 每一个数的父节点是它左边第一个比它大的数和它右边第一个比它大的数中,较小的那个。
● 如果一个数左边没有比它大的数,右边也没有。也就是说,这个数是整个数组的最大值,那么这个数是MaxTree的头节点。
那么3,4,5,1,2的MaxTree如下:
为什么通过这个方法能够正确地生成MaxTree呢?我们需要给出证明,证明分为如下两步。
1.通过这个方法,所有的数能生成一棵树,这棵树可能不是二叉树,但肯定是一棵树,而不是多棵树(森林)。
我们知道,在数组中的所有数都不同,而一个较小的数肯定会以一个比自己大的数作为父节点,那么最终所有的数向上找都会找到数组中的最大值,所以它们会有一个共同的头。证明完毕。
2.通过这个方法,所有的数最多都只有两个孩子。也就是说,这棵树可以用二叉树表示,而不需要多叉树。
要想证明这个问题,只需证明任何一个数在单独一侧,孩子数量都不可能超过1个即可。
假设a这个数在单独一侧有2个孩子,不妨设在右侧。假设这两个孩子一个是k1,另一个是k2,即
…a…k1…k2…
因为a是k1和k2的父,所以a>k1,a>k2。根据题意,k1和k2不相等,所以k1和k2可以分出大小,先假设k1是较小的,k2是较大的:
那么k1可能会以k2为父节点,而绝对不会以a为父节点,因为根据我们的方法,每一个数的父节点是它左边第一个比它大的数和它右边第一个比它大的数中较小的那个,又有a>k2。
再假设k2是较小的,k1是较大的:
那么k2可能会以k1为父节点,也绝对不会以a为父节点,因为根据我们的方法,k1才可能是k2左边第一个遇到的比k2大的数,而绝对不会轮到a。
总之,k1和k2肯定有一个不是a的孩子。
所以,任何一个数的单独一侧,其孩子数量都不可能超过1个,最多只会有1个。进而我们知道,任何一个数最多会有2个孩子,而不会有更多。
证明完毕。
以上证明了该方法是有效的,那么如何尽可能快地找到每一个数左右两边第一个比它大的数呢?利用栈。
找每个数左边第一个比它大的数,从左到右遍历每个数,栈中保持递减序列,新来的数不停地利用Pop出栈顶,直到栈顶比新数大或没有数。
以[3,1,2]为例,首先3入栈,接下来1比3小,无须pop出3,1入栈,并且确定了1往左第一个比它大的数为3。接下来2比1大,1出栈,2比3小,2入栈,并且确定了2往左第一个比它大的数为3。
用同样的方法可以求得每个数往右第一个比它大的数。
具体请参看如下代码中的getMaxTree方法。
public Node getMaxTree(int[] arr) {
Node[] nArr = new Node[arr.length];
for (int i = 0; i ! = arr.length; i++) {
nArr[i] = new Node(arr[i]);
}
Stack<Node> stack = new Stack<Node>();
HashMap<Node, Node> lBigMap = new HashMap<Node, Node>();
HashMap<Node, Node> rBigMap = new HashMap<Node, Node>();
for (int i = 0; i ! = nArr.length; i++) {
Node curNode = nArr[i];
while ((! stack.isEmpty()) && stack.peek().value < curNode.value) {
popStackSetMap(stack, lBigMap);
}
stack.push(curNode);
}
while (! stack.isEmpty()) {
popStackSetMap(stack, lBigMap);
}
for (int i = nArr.length - 1; i ! = -1; i--) {
Node curNode = nArr[i];
while ((! stack.isEmpty()) && stack.peek().value < curNode.value) {
popStackSetMap(stack, rBigMap);
}
stack.push(curNode);
}
while (! stack.isEmpty()) {
popStackSetMap(stack, rBigMap);
}
Node head = null;
for (int i = 0; i ! = nArr.length; i++) {
Node curNode = nArr[i];
Node left = lBigMap.get(curNode);
Node right = rBigMap.get(curNode);
if (left == null && right == null) {
head = curNode;
} else if (left == null) {
if (right.left == null) {
right.left = curNode;
} else {
right.right = curNode;
}
} else if (right == null) {
if (left.left == null) {
left.left = curNode;
} else {
left.right = curNode;
}
} else {
Node parent = left.value < right.value ? left : right;
if (parent.left == null) {
parent.left = curNode;
} else {
parent.right = curNode;
}
}
}
return head;
}
public void popStackSetMap(Stack<Node> stack, HashMap<Node, Node> map) {
Node popNode = stack.pop();
if (stack.isEmpty()) {
map.put(popNode, null);
} else {
map.put(popNode, stack.peek());
}
}
【题目】
给定一个整型矩阵map,其中的值只有0和1两种,求其中全是1的所有矩形区域中,最大的矩形区域为1的数量。
例如:
1 1 1 0
其中,最大的矩形区域有3个1,所以返回3。
再如:
1 0 1 1
1 1 1 1
1 1 1 0
其中,最大的矩形区域有6个1,所以返回6。
【难度】
校 ★★★☆
【解答】
如果矩阵的大小为O (N ×M ),本题可以做到时间复杂度为O (N ×M )。解法的具体过程为:
1.矩阵的行数为N ,以每一行做切割,统计以当前行作为底的情况下,每个位置往上的1的数量。使用高度数组height来表示。
例如:
map = 1 0 1 1
1 1 1 1
1 1 1 0
以第1行做切割后,height={1,0,1,1},height[j]表示目前的底上(第1行),j 位置往上(包括j 位置)有多少连续的1。
以第2行做切割后,height={2,1,2,2},注意到从第一行到第二行,height数组的更新是十分方便的,即height[j] = map[i][j]==0 ? 0 : height[j]+1。
以第3行做切割后,height={3,2,3,0}。
2.对于每一次切割,都利用更新后的height数组来求出以每一行为底的情况下,最大的矩形是什么。那么这么多次切割中,最大的那个矩形就是我们要的。
整个过程就是如下代码中的maxRecSize方法。步骤2的实现是如下代码中的maxRecFromBottom方法。
下面重点介绍一下步骤2如何快速地实现,这也是这道题最重要的部分,如果height数组的长度为M ,那么求解步骤2的过程可以做到时间复杂度为O (M )。
对于height数组,读者可以理解为一个直方图,比如{3,2,3,0},其实就是如图1-6所示的直方图。
图1-6
也就是说,步骤2的实质是在一个大的直方图中求最大矩形的面积。如果我们能够求出以每一根柱子扩展出去的最大矩形,那么其中最大的矩形就是我们想找的。比如:
● 第1根高度为3的柱子向左无法扩展,它的右边是2,比3小,所以向右也无法扩展,则以第1根柱子为高度的矩形面积就是3*1==3;
● 第2根高度为2的柱子向左可以扩1个距离,因为它的左边是3,比2大;右边的柱子也是3,所以向右也可以扩1个距离,则以第2根柱子为高度的矩形面积就是2*3==6;
● 第3根高度为3的柱子向左没法扩展,向右也没法扩展,则以第3根柱子为高度的矩形面积就是3*1==3;
● 第4根高度为0的柱子向左没法扩展,向右也没法扩展,则以第4根柱子为高度的矩形面积就是0*1==0;
所以,当前直方图中最大的矩形面积就是6,也就是图1-6中虚线框住的部分。
考查每一根柱子最大能扩多大,这个行为的实质就是找到柱子左边刚比它小的柱子位置在哪里,以及右边刚比它小的柱子位置在哪里。这个过程怎么计算最快呢?用栈。
为了方便表述,我们以height={3,4,5,4,3,6}为例说明如何根据height数组求其中的最大矩形。具体过程如下:
1.生成一个栈,记为stack,从左到右遍历height数组,每遍历一个位置,都会把位置压进stack中。
2.遍历到height的0位置,height[0]=3,此时stack为空,直接将位置0压入栈中,此时stack从栈顶到栈底为{0}。
3.遍历到height的1位置,height[1]=4,此时stack的栈顶为位置0,值为height[0]=3,又有height[1]>height[0],那么将位置1直接压入stack。这一步体现了遍历过程中的一个关键逻辑:只有当前i位置的值height[i]大于当前栈顶位置所代表的值(height[stack.peek()]),则i位置才可以压入stack。
所以可以知道,stack中从栈顶到栈底的位置所代表的值是依次递减,并且无重复值,此时stack从栈顶到栈底为{1,0}。
4.遍历到height的2位置,height[2]=5,与步骤3的情况完全一样,所以直接将位置2压入stack,此时stack从栈顶到栈底为{2,1,0}。
5.遍历到height的3位置,height[3]=4,此时stack的栈顶为位置2,值为height[2]=5,又有height[3]<height[2]。此时又出现了一个遍历过程中的关键逻辑,即如果当前i 位置的值height[i]小于或等于当前栈顶位置所代表的值(height[stack.peek()]),则把栈中存的位置不断弹出,直到某一个栈顶所代表的值小于height[i],再把位置i 压入,并在这期间做如下处理:
1)假设当前弹出的栈顶位置记为位置j ,弹出栈顶之后,新的栈顶记为k 。然后我们开始考虑位置j 的柱子向右和向左最远能扩到哪里。
2)对位置j 的柱子来说,向右最远能扩到哪里呢?
如果height[j]>height[i],那么i -1位置就是向右能扩到的最远位置。因为j 之所以被弹出,就是因为遇到了第一个比位置j 值小的位置。
如果height[j]==height[i],那么i -1位置不一定是向右能扩到的最远位置,只是起码能扩到的位置。那怎么办呢?
可以肯定的是,在这种情况下,i 位置的柱子向左必然也可以扩到j 位置。也就是说,j 位置的柱子扩出来的最大矩形和i 位置的柱子扩出来的最大矩形是同一个。
所以,此时可以不再计算j 位置的柱子能扩出来的最大矩形,因为位置i 肯定要压入到栈中,那就等位置i 弹出的时候再说。
3)对位置j 的柱子来说,向左最远能扩到哪里呢?
肯定是k +1位置。首先,height[k+1..j-1]之间不可能有小于或等于height[k]的值,否则k 位置早从栈里弹出了。
然后因为在栈里k 位置和j 位置原本是相邻的,并且从栈顶到栈底的位置所代表的值是依次递减并且无重复值,所以在height[k+1..j-1]之间不可能有大于或等于height[k],同时又小于或等于height[j]的,因为如果有这样的值,k 和j 在栈中就不可能相邻。
所以,height[k+1..j-1]之间的值必然是既大于height[k],又大于height[j]的,所以j 位置的柱子向左最远可以扩到k +1位置。
4)综上所述,j 位置的柱子能扩出来的最大矩形为(i-k-1)*height[j]。
以例子来说明:
① i==3,height[3]=4,此时stack的栈顶为位置2,值为height[2]=5,故height[3]<=height[2],所以位置2被弹出(j==2),当前栈顶变为1(k==1)。位置2的柱子扩出来的最大矩形面积为(3-1-1)*5==5。
② i==3,height[3]=4,此时stack的栈顶为位置1,值为height[1]=4,故height[3]<=height[1],所以位置1被弹出(j==1),当前栈顶变为1(k==0)。位置1的柱子扩出来的最大矩形面积为(3-0-1)*4==8,这个值实际上是不对的(偏小),但在位置3被弹出的时候是能够重新正确计算得到的。
③ i==3,height[3]=4,此时stack的栈顶为位置0,值为height[0]=3,这时height[3]<=height[2],所以位置0不弹出。
④将位置3压入stack,stack从栈顶到栈底为{3,0}。
6.遍历到height的4位置,height[4]=3。与步骤5的情况类似,以下是弹出过程:
1)i==4,height[4]=3,此时stack的栈顶为位置3,值为height[3]=4,故height[4]<=height[3],所以位置3被弹出(j==3),当前栈顶变为0(k==0)。位置3的柱子扩出来的最大矩形面积为(4-0-1)*4==12。这个最大面积也是位置1的柱子扩出来的最大矩形面积,在位置1被弹出时,这个矩形其实没有找到,但在位置3这里找到了。
2)i==4,height[4]=3,此时stack的栈顶为位置0,值为height[0]=3,故height[4]<=height[0],所以位置0被弹出(j==0),当前没有了栈顶元素,此时可以认为k==-1。位置0的柱子扩出来的最大矩形面积为(4-(-1)-1)*3==12,这个值实际上是不对的(偏小),但在位置4被弹出时是能够重新正确计算得到的。
3)栈已经为空,所以将位置4压入stack,此时从栈顶到栈底为{4}。
7.遍历到height的5位置,height[5]=6,情况和步骤3类似,直接压入位置5,此时从栈顶到栈底为{5,4}。
8.遍历结束后,stack中仍有位置没有经历扩的过程,从栈顶到栈底为{5,4}。此时因为height数组再往右不能扩出去,所以认为i==height.length==6且越界之后的值极小,然后开始弹出留在栈中的位置:
1)i==6,height[6]极小,此时stack的栈顶为位置5,值为height[5]=6,故height[6]<=height[5],所以位置6被弹出(j==6),当前栈顶变为4(k==4)。位置5的柱子扩出来的最大矩形面积为(6-4-1)*6==6。
2)i==6,height[6]极小,此时stack的栈顶为位置4,值为height[4]=3,故height[6]<=height[4],所以位置4被弹出(j==4),栈空了,此时可以认为k==-1。位置4的柱子扩出来的最大矩形面积为(6-(-1)-1)*3==18。这个最大面积也是位置0的柱子扩出来的最大矩形面积,在位置0被弹出的时候,这个矩形其实没有找到,但在位置4这里找到了。
3)栈已经空了,过程结束。
9.整个过程结束,所有找到的最大矩形面积中18是最大的,所以返回18。
研究以上9个步骤时我们发现,任何一个位置都仅仅进出栈1次,所以时间复杂度为O (M )。既然每做一次切割处理的时间复杂度为O (M ),一共做N 次,则总的时间复杂度为O (N ×M )。
全部过程参看如下代码中的maxRecSize方法。9个步骤的详细过程参看代码中的maxRecFromBottom方法。
public int maxRecSize(int[][] map) {
if (map == null || map.length == 0 || map[0].length == 0) {
return 0;
}
int maxArea = 0;
int[] height = new int[map[0].length];
for (int i = 0; i < map.length; i++) {
for (int j = 0; j < map[0].length; j++) {
height[j] = map[i][j] == 0 ? 0 : height[j] + 1;
}
maxArea = Math.max(maxRecFromBottom(height), maxArea);
}
return maxArea;
}
public int maxRecFromBottom(int[] height) {
if (height == null || height.length == 0) {
return 0;
}
int maxArea = 0;
Stack<Integer> stack = new Stack<Integer>();
for (int i = 0; i < height.length; i++) {
while (! stack.isEmpty() && height[i] <= height[stack.peek()]) {
int j = stack.pop();
int k = stack.isEmpty() ? -1 : stack.peek();
int curArea = (i - k - 1) * height[j];
maxArea = Math.max(maxArea, curArea);
}
stack.push(i);
}
while (! stack.isEmpty()) {
int j = stack.pop();
int k = stack.isEmpty() ? -1 : stack.peek();
int curArea = (height.length - k - 1) * height[j];
maxArea = Math.max(maxArea, curArea);
}
return maxArea;
}
【题目】
给定数组arr和整数num,共返回有多少个子数组满足如下情况:
max(arr[i..j]) - min(arr[i..j]) <= num
max(arr[i..j])表示子数组arr[i..j]中的最大值,min(arr[i..j])表示子数组arr[i..j]中的最小值。
【要求】
如果数组长度为N ,请实现时间复杂度为O (N )的解法。
【难度】
校 ★★★☆
【解答】
首先介绍普通的解法,找到arr的所有子数组,一共有O (N 2 )个,然后对每一个子数组做遍历找到其中的最小值和最大值,这个过程时间复杂度为O (N ),然后看看这个子数组是否满足条件。统计所有满足的子数组数量即可。普通解法容易实现,但是时间复杂度为O (N 3 ),本书不再详述。最优解可以做到时间复杂度O (N ),额外空间复杂度O (N ),在阅读下面的分析过程之前,请读者先阅读本章“生成窗口最大值数组”问题,本题所使用到的双端队列结构与解决“生成窗口最大值数组”问题中的双端队列结构含义基本一致。
生成两个双端队列qmax和qmin。当子数组为arr[i..j]时,qmax维护了窗口子数组arr[i..j]的最大值更新的结构,qmin维护了窗口子数组arr[i..j]的最小值更新的结构。当子数组arr[i..j]向右扩一个位置变成arr[i..j+1]时,qmax和qmin结构可以在O (1)的时间内更新,并且可以在O (1)的时间内得到arr[i..j+1]的最大值和最小值。当子数组arr[i..j]向左缩一个位置变成arr[i+1..j]时,qmax和qmin结构依然可以在O (1)的时间内更新,并且在O (1)的时间内得到arr[i+1..j]的最大值和最小值。
通过分析题目满足的条件,可以得到如下两个结论:
● 如果子数组arr[i..j]满足条件,即max(arr[i..j])-min(arr[i..j])<=num,那么arr[i..j]中的每一个子数组,即arr[k..l](i<=k<=l<=j)都满足条件。我们以子数组arr[i..j-1]为例说明,arr[i..j-1]最大值只可能小于或等于arr[i..j]的最大值,arr[i..j-1]最小值只可能大于或等于arr[i..j]的最小值,所以arr[i..j-1]必然满足条件。同理,arr[i..j]中的每一个子数组都满足条件。
● 如果子数组arr[i..j]不满足条件,那么所有包含arr[i..j]的子数组,即arr[k..l](k<=i<=j<=l)都不满足条件。证明过程同第一个结论。
根据双端队列qmax和qmin的结构性质,以及如上两个结论,设计整个过程如下:
1.生成两个双端队列qmax和qmin,含义如上文所说。生成两个整型变量i和j,表示子数组的范围,即arr[i..j]。生成整型变量res,表示所有满足条件的子数组数量。
2.令j不断向右移动(j++),表示arr[i..j]一直向右扩大,并不断更新qmax和qmin结构,保证qmax和qmin始终维持动态窗口最大值和最小值的更新结构。一旦出现arr[i..j]不满足条件的情况,j向右扩的过程停止,此时arr[i..j-1]、arr[i..j-2]、arr[i..j-3]、...、arr[i..i]一定都是满足条件的。也就是说,所有必须以arr[i]作为第一个元素的子数组,满足条件的数量为j -i 个。于是令res+=j-i。
3.当进行完步骤2,令i向右移动一个位置,并对qmax和qmin做出相应的更新,qmax和qmin从原来的arr[i..j]窗口变成arr[i+1..j]窗口的最大值和最小值的更新结构。然后重复步骤2,也就是求所有必须以arr[i+1]作为第一个元素的子数组中,满足条件的数量有多少个。
4.根据步骤2和步骤3,依次求出以arr[0]、arr[1]、...、arr[N-1]作为第一个元素的子数组中满足条件的数量分别有多少个,累加起来的数量就是最终的结果。
上述过程中,所有的下标值最多进qmax和qmin一次,出qmax和qmin一次。i和j的值也不断增加,并且从来不减小。所以整个过程的时间复杂度为O (N )。
最优解全部实现请参看如下代码中的getNum方法。
public int getNum(int[] arr, int num) {
if (arr == null || arr.length == 0) {
return 0;
}
LinkedList<Integer> qmin = new LinkedList<Integer>();
LinkedList<Integer> qmax = new LinkedList<Integer>();
int i = 0;
int j = 0;
int res = 0;
while (i < arr.length) {
while (j < arr.length) {
while (! qmin.isEmpty() && arr[qmin.peekLast()] >= arr[j]) {
qmin.pollLast();
}
qmin.addLast(j);
while (! qmax.isEmpty() && arr[qmax.peekLast()] <= arr[j]) {
qmax.pollLast();
}
qmax.addLast(j);
if (arr[qmax.getFirst()] - arr[qmin.getFirst()] > num) {
break;
}
j++;
}
if (qmin.peekFirst() == i) {
qmin.pollFirst();
}
if (qmax.peekFirst() == i) {
qmax.pollFirst();
}
res += j - i;
i++;
}
return res;
}