算法问题有很大一部分都可以将问题归约成子问题,通过解决子问题而解决原问题。这一策略常见的使用场景是divide and conquer,这一策略也可以轻易地用来解决很多其它问题。本文通过Rotate Array这一例子讲解这个技巧。
Rotate Array
https://leetcode.com/problems/rotate-array/
Rotate an array of n elements to the right by k steps.
For example, with n = 7 and k = 3, the array [1,2,3,4,5,6,7] is rotated to [5,6,7,1,2,3,4].
###分析 问题的大意就是,将数组的后面一部分挪到前面。我们可以简单地分配一个新数组,将后一部分拷贝到新数组头部,再将前面一部分拷贝到后面。这种做法简单,并且也非常容易并行。然而,需要额外的空间开销。面试官很可能会要求你给出in place的方法。怎么办呢?
先考虑简单的情况,很容易想到的一种情形,k是n的倍数,那么根本不用挪。除此之外,还有一种简单情形,n是偶数,k=n/2。这种情形只需要交换i和n-k+i位置的数就可以。
第一种简单情形看起来没啥用处。第二种情形又能给我们解决一般情形带来什么启发呢?对于array=[1,2,3,4,5,6],k=3的情况,我们只需要交换前后两半的元素,就可以得到[4,5,6,1,2,3]。那么对于array=[1,2,3,4,5,6,7],k=3的情况呢?
没什么思路,不过无妨先按照前面的办法,先将1,2,3和5,6,7交换看看,这样就变成了[5,6,7,4,1,2,3]。这个时候我们可以发现,5,6,7已经到了最终位置(我们最终要的是[5,6,7,1,2,3,4]),现在我们还要做的就是将[4,1,2,3]变成[1,2,3,4]。也许你还在思考接下来该怎么办,但是我要告诉你,问题其实已经解决了。为什么你没看出来呢?缺少善于发现子结构的眼睛啊骚年!其实[4,1,2,3]->[1,2,3,4]不就是原来的问题中n=4,k=3的情形吗?
###编码 如果用递归直接翻译上面思路的编码,那么递归调用栈仍然可能用上O(n)的空间。
void rotate(int nums[], int n, int k) {
k=k%n;
if (n==0 || k == 0) return;
if (k<=n-k) { //first part is longer
for (int a = 0; a < k; ++a) {
swap(nums[a], nums[a+n-k]);
}
rotate(nums+k, n-k, k);
} else { //second part is longer
for (int a = 0; a < n-k; ++a) {
swap(nums[a], nums[a+n-k]);
}
rotate(nums+n-k, k, k-(n-k));
}
}
当然编译器可能会发现这是个尾递归,从而使得实际的空间使用为常数。不过面试官接不接受就是另一回事儿了,兴许他会问你编译器是如何做这个优化的。
我们也可以主动提出手动做这个优化,并且可以做得更漂亮。观察代码,我们可以发现,递归调用的三个参数在变。而这三个参数无非指明了前后两段,我们要做的是把后一段挪到前一段。我们需要记录的是前一段从哪开始,中点在哪,以及后一段从哪开始。并且当前面一段用来交换的游标碰到中点或者后面一段用来交换的游标碰到结尾的时候注意更新。
void rotate(int nums[], int n, int k) {
k = k%n;
if (k==0) return;
int first = 0;
int second = n-k;
int middle = n-k;
while (first != second) {
swap(nums[first], nums[second]);
first++;second++;
if (second == n) {
second = middle;
} else if (first == middle) {
middle = second;
}
}
}
###使用要义 **有意识**地注意子结构。如果我们不去有意识地注意子结构,可能在参考第二种简单情形一样进行部分交换之后仍然不知所措。
类似斐波那契数列这样的问题,由于子结构由一个变量(数列长度)即可定义,我们可能很快就能发现。有时候子结构可能会像这个问题一样,需要三个变量才能定义,也要能及时发现才好。
###相关内容 对于这个问题,还有一种时间O(nk),空间O(1)的办法,将最后一个挪到第一个,也就是拿出来,将所有元素后移一位,放到第一个,重复做k遍。
还有一个时间O(n),空间O(1)的做法,将前面半段翻转,后面半段翻转,整个翻转。这是一个经典做法,不过反正我想不到。
子结构的应用非常广泛,凡涉及到递归的几乎都有其用武之地,有两个常见的地方:divide and conquer和dynamic programming。
对于这个问题,我们先解决问题的一部分,然后解决子问题。有时候,也相反,先解决子问题,再用子问题的结果得出原问题的结果,比如dynamic programming。这是两个方向。