改进二涉及到分治思想和递归,不仅省去了那个占空间的标记数组,且同样高效。下面是原文中的改进描述:
我们可以将所有满足xi ≤ ⌊n/2⌋的整数放入一个子序列A';将剩余的其他整
数放入另外一个序列A''。根据公式1,如果序列A'的长度正好是⌊n/2⌋,这说明
前一半的整数已经“满了”,最小的可用整数一定可以在A''中递归地找到。否
则,最小的可用整数可以在A'中找到。总之,通过这一划分,问题的规模减小
了。
这里要清楚一点:描述说把满足xi <= ⌊n/2⌋
的整数放入子序列A'
,其余放入A''
,这里说的放入,并不是要创建两个新列表,然后将元素存到里边(如果是这样的话,那肯定得消耗额外空间的),而是把整个列表A
中的元素划分成两部分(这两部分并不一定均等),左边部分的元素<= ⌊n/2⌋
,右边部分的元素> ⌊n/2⌋
,n是列表A
的长度,元素还是在列表A
中,只不过将其左边部分称作子序列A'
右边部分称作子序列A''
。至于如何将元素划分为左右两部分,这个到代码部分再讲(这里的划分,也并非将元素严格地递增排序,严格的递增排序,除两个边界元素,其余每个元素肯定比其前一个元素大,比接下来的一个小,而是以⌊n/2⌋
为临界点,只需和该点比较就行了,左边的元素肯定小于右边的)。
原文中似乎并没有公式1,这个不管它,理解它后面那句话就可以了。什么情况下序列A'
的长度正好是⌊n/2⌋
呢?那就是序列A'
中的元素必定是[0, n/2]
的某个排列(还不清楚的话,举个实栗就知道了:有3个元素,且这3个元素不大于3,那么这3个元素只能是{0, 1, 2, 3}
。好吧,举完栗子我才发现原文这句长度正好是⌊n/2⌋是错的,不应该是⌊n/2⌋,不过代码中的描述是正确的,到代码部分再讲下这个问题0),这时最小可用ID只能在序列A''
中找了。若序列A''
的长度也是⌊n/2⌋
,且元素是[(n/2) + 1, n)
的某个排列,那么最小可用ID必定是n
,反之最小可用ID必定能在A''
中找到。以这种思想递归的在列表A
中找下去,最终可以得到结果。
再看下下面这段话(来自原文),有助于代码的理解。没什么好解释的,看完就懂了:
需要注意的是,当我们在子序列A''中递归查找时,边界情况发生了一些变
化,我们不再是从0开始寻找最小可用整数,查找的下界变成了⌊n/2⌋ + 1。因
此我们的算法应定义为minf_ree(A, l, u),其中l和u分别是上下界。
递归结束的边界条件是当待查找的序列变为空的时候,此时我们只需要返回
下界作为结果即可。
根据上述思路,分而治之的解法可以形式化地定义为一个函数:
截图也来自原文。这图并不涉及太过数学的东西,上面两段描述文字理解的话,这个也可以看懂,用等式描述就是简单形象,不像文字那样累赘。说下其中几个的意思:|A|
表示列表A
的长度;φ
表示空集;这句∀x ∈ A ∧ x ≤ m
读做任意x
属于A
,存在x
小于等于m
,意思就是序列A'
中的元素是列表A
中不大于m
的元素。
看懂了文字,必须得看代码,文字和代码并不是一回事。原作者用了递归和迭代两个版本(其实还有函数式编程的实现,这个不看了,语言不同),先看C语言递归版:
// binary search
// xs: array, n: length of xs
// l: lower bound, u: upper bound
// 定义了一个函数,从含有n个元素的数组xs中查找最小可用ID
// l是下边界,u是上边界,其实就是对应数组元素的index
// 如果是第一层递归的话,l就是数组xs的第一个元素的index,u是第n个元素的index
int binary_search(int* xs, int n, int l, int u){
// 递归结束条件就是列表的长度为0了,这时左边界l就是最小可用ID
// 为什么u不行?当n=0时,表明所有不可用ID都排除了,
// 此时l正好为前一个状态的(l + u)的值的一半再加一,就是第一个最小可用ID,而u会比l小一
// 这样说也不是很清楚,拿这几个列表:xs:{φ}, xs:{0},xs:{1},xs:{1, 2, 3}试试就知道了
if(n==0) return l;
// 按描述里说的应该 m=n/2,实际上应该是下面这种情况,因为m为数组xs中每次折半后的
// 元素的index,是相对于xs中的绝对位置,而不是子序列中的位置。(l + u) / 2 <= n / 2
// 只根据n是无法确定每次折半后的index的哦。
int m = (l + u) / 2;
// 这两个变量是用来将列表中的元素排序成左右两个子序列的
int right, left = 0;
//0 ... <=m ... left ... >m ... right ...? ...
// 遍历从0到n-1的元素
for(right = 0; right < n; ++ right)
// 如果当前以right为index的元素<=m
if(xs[right] <= m){
// 则和以left为index的元素交换,这时xs[left]是<=m的。拿个具体的数组按此过程执行一遍就清楚了
swap(xs[left], xs[right]);
// 同时将left这个index指向下一位
++left;
} // 执行完这个for循环后,当前列表的元素就分成左右两个序列了
// 将列表的元素分为左右两边后,此时left为最后一个<=m的元素的下一个元素的index,
// 也就是第一个>m的元素的index。其实也代表<=m的元素个数
// 然后判断<=m的元素个数是否等于整个数组折半后的长度,m-l+1是计算整个数组折半后左半边的长度
// 相等就表示左边的子序列元素‘满了’,不理解的可以看前面的解释序列 A’ 的长度如何=n/2
if(left == m - l + 1) // 上边提到的 问题0 ,正确的是m-l+1,而不是n/2,当n为奇/偶数时,这两个值可能相等或不等
// 这时,最小可用ID肯定不在左边这个序列了,因为该序列的元素为[0, m]的某个排列
// 然后递归地在右边的序列查找,而右边序列的元素的起始地址是从xs+left开始的,
// 长度为总长n减去左边序列的长度left,边界l为m+1,边界u不变,拿个具体数组看下就知道这是怎么回事了
return binary_search(xs+left, n-left, m+1, u);
else
// 如果左边序列‘没满’,则最小ID肯定可以在左边这个序列递归地找到,
// 因为至少有一个元素是不在[0, m]中的
// 左边序列的元素的地址从xs开始,长度为left,边界l不变,边界u则为m
return binary_search(xs, left, l, m);
}
// min-free, divide and conquer, with recursion.
// 这个函数只是对上面那个函数的封装
int dc_min_free(int* xs, int n){
return binary_search(xs, n, 0, n-1);
}
要理解上面的递归函数,当然要理解递归,这里不是讲递归,就不细讲了。很粗略地讲下吧:递归必须有个终止条件,当达到这个条件时,递归才可以退出,问题也可以得到解决,没有终止条件的话,递归是解决不了问题的;然后,还有一系列解决问题的步骤,这个步骤可以解决相同的问题,比如一个大问题和由这个大问题分解后的一系列小问题;如果执行完上面的步骤,问题还没得到解决,就递归地执行上面的步骤,直到问题解决。这是我对递归解决问题的一点理解吧。
看完递归版,再看下迭代版(代码来自原文):
// min-free, divide and conquer, eliminate recursion.
int dc_min_free_iter(int* xs, int n){
int l=0;
int u=n-1;
// 迭代的结束条件就是n=0时,n=0就表明我们将所有不可用的ID都排除掉了
while(n){
// 下面这些后和递归中的一样,就不多说了
int m = (l + u) / 2;
int right, left = 0;
//0 ... <=m ... left ... >m ... right ...? ...
for(right = 0; right < n; ++ right)
if(xs[right] <= m){
swap(xs[left], xs[right]);
++left;
}
// 同样的,当左边的序列‘满了’
if(left == m - l + 1){
// 我们就循环地在右边查找。
xs = xs + left; // 右边元素地址从xs+left开始
n = n - left; // 元素个数当然就是减去左边元素个数啦
l = m+1; // 下边界变为m+1,因为左边的序列包括m这个元素
}
// 反之,左边‘没满’
else{
n = left; // 左边的元素地址还是从xs开始,只不过元素个数变为了left
u = m; // 上边界发生了改变,为m,左边序列包括m
}
}
// 同理,当n为0时,下边界就是最小可用ID了
return l;
}
整个改进二的理解就是这样了:)