分治算法——归并排序与最大连续子数组问题

本文是《算法导论》第二章、第三章、第四章一部分内容的简要概括

归并排序

先从一个简单的例子开始。假如面前有两堆已经排好序的扑克牌,牌面朝上,最小的牌在顶上,我们希望把两堆牌按照顺序合成一堆,牌面朝下。基本的操作就像是这样:从两堆牌的顶上挑一张最小的牌,翻转过来放在输出堆,不断重复这个步骤直到两堆牌中有一堆牌空了,到这个时候,把另一堆牌翻转、挪动到输出堆,有序的两堆牌的合并就完成了。

依此类推,如何对一个数组进行排序呢?
回答是:获得这个数组的两个有序子数组,然后合并。

嗯?那怎么得到有序的子数组呢?
回答是:获得两个有序的子子数组,然后合并。

这种递推关系用数学语言表达就像是这样: f ( n ) = f ( n − 1 ) + a f(n)=f(n-1)+a f(n)=f(n1)+a,想要知道f(n)的值就必须知道f(n-1)的值,而如果想要求解这个递推式,我们需要一个基本条件,比如 f ( 1 ) = 1 f(1)=1 f(1)=1。与此类似地,数组不断一分为二,直到子数组长度为1时就不需要排序了——它天然有序,长度为1的数组便作为归并排序的基本情况。

上面的过程展示了分治算法的基本思路:将原问题分解为几个规模较小但类似于原问题的子问题,递归地求解这些子问题,然后再合并这些子问题的解来建立原问题的解。

算法复杂度分析

当一个算法包含对自身的递归调用时,我们可以用递归方程来刻画它的运行时间。

假设 T ( n ) T(n) T(n)是规模为n的某个问题的运行时间,若问题规模足够小,比如小于某个常量c,直接求解只需要花费常量时间,记为 Θ ( 1 ) \Theta(1) Θ(1)( Θ \Theta Θ是什么意思?)

假设把原问题分解为a个子问题,每个子问题的规模是原问题的1/b(注意,虽然对于归并算法来说a=b=2,但是对于其他的分治算法,a=b并不是必然的)为了求解规模为 b / n b/n b/n的子问题,需要 T ( b / n ) T(b/n) T(b/n)的时间,求解所有子问题需要 a T ( b / n ) aT(b/n) aT(b/n)的时间。设问题分解成子问题需要时间D(n),合并子问题的解成原问题的解需要时间C(n),有如下的递归式:
T ( n ) = { Θ ( 1 ) 若 n ≤ c a T ( n / b ) + D ( n ) + C ( n ) 其 他 T(n)=\begin{cases} \Theta(1) & 若n \leq c \\ aT(n/b)+D(n)+C(n) & 其他 \end{cases} T(n)={ Θ(1)aT(n/b)+D(n)+C(n)nc
根据这个递推式,我们可以很容易地得出归并排序的递推公式:
T ( n ) = { Θ ( 1 ) 若 n = 1 2 T ( n / 2 ) + Θ ( n ) 若 n > 1 T(n)=\begin{cases} \Theta(1) & 若n=1 \\ 2T(n/2)+\Theta(n) & 若n>1 \end{cases} T(n)={ Θ(1)2T(n/2)+Θ(n)n=1n>1
以上递推式的解是 Θ ( n lg ⁡ n ) \Theta(n\lg n) Θ(nlgn)。这里不给出严格的数学证明,仅仅简单给出一个解释:

设常量c为求解一个常数时间问题的时间,把递推式重新改写为:
T ( n ) = { c 若 n = 1 2 T ( n / 2 ) + c n 若 n > 1 T(n)=\begin{cases} c & 若n=1 \\ 2T(n/2)+cn & 若n>1 \end{cases} T(n)={ c2T(n/2)+cnn=1n>1
假设现在 n n n正好是2的幂,我们可以构造一棵如下的递归树来表示每一层递归所需要的代价:

在这里插入图片描述

忽略低阶项和常量c便给出了期望的结果 Θ ( n lg ⁡ n ) \Theta(n\lg n) Θ(nlgn)

渐进记号

Θ \Theta Θ符号

对于一个给定的函数 g ( n ) g(n) g(n),用 Θ ( g ( n ) ) \Theta(g(n)) Θ(g(n))来表示以下函数的集合:
Θ ( g ( n ) ) = { f ( n ) : 存 在 正 常 量 c 1 、 c 2 和 n 0 , 使 得 对 所 有 n ≥ n 0 , 有 0 ≤ c 1 g ( n ) ≤ f ( n ) ≤ c 2 g ( n ) } \Theta(g(n))=\{f(n):存在正常量c_{1}、c_{2}和n_{0},使得对所有n\geq n_{0},有0\leq c_{1}g(n)\leq f(n) \leq c_{2}g(n)\} Θ(g(n))={ f(n):c1c2n0,使nn0,0c1g(n)f(n)c2g(n)}
上面的定义指出,如果存在常量,在n足够大的情况下把函数 f ( n ) f(n) f(n)夹在 g ( n ) g(n) g(n)两边,我们就可以说 f ( n ) ∈ Θ ( g ( n ) ) f(n)\in \Theta(g(n)) f(n)Θ(g(n))。换句话说,对所有的 n ≥ n 0 n\geq n_{0} nn0,函数 f ( n ) f(n) f(n)在一个常量因子等于 g ( n ) g(n) g(n)。我们称 g ( n ) g(n) g(n) f ( n ) f(n) f(n)的一个渐进紧确界。

通常将 f ( n ) ∈ Θ ( g ( n ) ) f(n)\in \Theta(g(n)) f(n)Θ(g(n))记为 f ( n ) = Θ ( g ( n ) ) f(n)=\Theta(g(n)) f(n)=Θ(g(n))

可以发现上述定义和极限的定义类似,如果对高等数学还有一些印象的话,很快就能明白为什么我们会把归并排序的总代价 c n lg ⁡ n + c n cn\lg n+cn cnlgn+cn直接记为 Θ ( n lg ⁡ n ) \Theta(n\lg n) Θ(nlgn) n lg ⁡ n n\lg n nlgn这一项比 n n n这一项更为高阶,当n足够大时,低阶项的值是无足轻重的,而具体的系数在确定渐进界时也是无关紧要的细节。

由于常量可以说是一个0阶的多项式,一般记为 Θ ( n 0 ) \Theta(n^{0}) Θ(n0) Θ ( 1 ) \Theta(1) Θ(1)

O O O符号

Θ \Theta Θ符号给出了一个函数的渐进上界和渐进下界,当只有一个渐进上界时,使用 O O O记号。对于一个给定的函数 g ( n ) g(n) g(n),用 O ( g ( n ) ) O(g(n)) O(g(n))来表示以下函数的集合:
O ( g ( n ) ) = { f ( n ) : 存 在 正 常 量 c 和 n 0 , 使 得 对 所 有 n ≥ n 0 , 有 0 ≤ f ( n ) ≤ c g ( n ) } O(g(n))=\{f(n):存在正常量c和n_{0},使得对所有n\geq n_{0},有0\leq f(n) \leq cg(n)\} O(g(n))={ f(n):cn0,使nn0,0f(n)cg(n)}
也就是说,对于所有的 n ≥ n 0 n\geq n_{0} nn0,函数 f ( n ) f(n) f(n)的值总小于等于 c g ( n ) cg(n) cg(n)

显而易见, f ( n ) = Θ ( g ( n ) ) f(n)=\Theta(g(n)) f(n)=Θ(g(n))蕴含着 f ( n ) = O ( g ( n ) ) f(n)=O(g(n)) f(n)=O(g(n)),用集合论的说法就是 Θ ( g ( n ) ) ⊆ O ( g ( n ) ) \Theta(g(n))\subseteq O(g(n)) Θ(g(n))O(g(n))

要注意到, O O O符号经常会被用来非形式化地描述渐进确界,也就是 Θ \Theta Θ符号的含义,在算法文献(至少是《算法导论》中)中,更标准的做法是区分渐进上界和渐进下界。

Ω \Omega Ω符号

Ω \Omega Ω符号提供了函数的渐进下界,对于一个给定的函数 g ( n ) g(n) g(n),用 Ω ( g ( n ) ) \Omega(g(n)) Ω(g(n))来表示以下函数的集合:
Ω ( g ( n ) ) = { f ( n ) : 存 在 正 常 量 c 和 n 0 , 使 得 对 所 有 n ≥ n 0 , 有 0 ≤ c g ( n ) ≤ f ( n ) } \Omega(g(n))=\{f(n):存在正常量c和n_{0},使得对所有n\geq n_{0},有0\leq cg(n) \leq f(n) \} Ω(g(n))={ f(n):cn0,使nn0,0cg(n)f(n)}

对数底数的说明

对于对数而言,有如下换底公式:
log ⁡ b a = log ⁡ c a log ⁡ c b \log_ba=\frac{\log_ca}{\log_cb} logba=logcblogca
例如
log ⁡ 2 n = log ⁡ 3 n log ⁡ 3 2 \log_2n=\frac{\log_3n}{\log_32} log2n=log32log3n
对数的底从一个常量到另一个常量的更换仅仅使对数的值改变了一个常量因子,所以当不需要关心这些常量因子时,会经常直接使用记号" lg ⁡ n \lg n lgn"

最大连续子数组问题

  • 输入一个整型数组,数组中的一个或连续多个整数组成一个子数组。求子数组的和的最大值。

首先需要明确一点:只有在数组中包含负数的情况下,最大子数组问题才有意义,不然,最大值肯定是数组所有元素之和。

使用分治策略求解

假如我们打算将数组A[low..high]尽量分成大小相等的两个子数组,例如从中央位置mid开始分开,形成两个子数组A[low..mid]A[mid+1..high],那么,A[low..high]的任何连续子数组所处的位置一定是以下三种情况之一:

  • 完全位于A[low…mid]内
  • 完全位于A[mid+1…high]内
  • 跨越了中点

那么,A[low…high]的一个最大子数组所处的位置必定是以上三种情况之一。换句话说,原数组的最大子数组是左边、右边和跨越中间的最大子数组的较大者。递归的基本情况是数组长度为1——这时的最大子数组就是它自身。

根据分治思想,我们可以递归求解A[low…mid]和A[mid+1…high]的最大子数组问题,唯一的问题只剩下如何计算跨越中点的子数组。注意,这个问题不能递归求解,因为它有个限制:数组必须跨越中点。

我们可以在线性时间 Θ ( n ) \Theta(n) Θ(n)内求出跨越中点的最大子数组。首先,跨越中点的子数组由两个子数组A[i…mid]和A[mid+1…j]组成,要找出这两个子数组,需要从mid开始,往左、往右各扫描一遍,往左扫描到low,往右扫描到high(必须扫描到左右端点,否则结果可能不正确)

那么如何求最大子数组的值?我们只需要在扫描时,读取值并不断累加,维护数组元素之和sum,再维护一个最大值max,sum超过max则更新max。左边扫描完毕后,跨越中点左半边数组的值即为max。中点右边可同理求出A[mid+1…j]的最大值max,两个加起来就是跨越中点的子数组所能达到的最大和。(原书上有伪代码)

参考上面的递推公式,我们可以给出最大子数组问题的递推式:
T ( n ) = { Θ ( 1 ) 若 n = 1 2 T ( n / 2 ) + Θ ( n ) 若 n > 1 T(n)=\begin{cases} \Theta(1) & 若n=1 \\ 2T(n/2)+\Theta(n) & 若n>1 \end{cases} T(n)={ Θ(1)2T(n/2)+Θ(n)n=1n>1
合并子数组的解之前需要一遍线性扫描,因此合并代价是 Θ ( n ) \Theta(n) Θ(n)。这个递归式的解是 T ( n ) = Θ ( n lg ⁡ n ) T(n)=\Theta(n\lg n) T(n)=Θ(nlgn)

渐进确界为 Θ ( n ) \Theta(n) Θ(n)的算法

使用这个分治算法来求解最大子数组问题的同时,我们默认了所有子数组它们各自的最大子数组是毫无关系的,例如,对于数组[-2,1,-3,4,-1,2,1,-5,4],递归到底后,-2与1合并成[-2,1],然后再与[-3,4]合并成[-2,1,-3,4],在这个过程中,我们需要求解三次跨越中点的最大数组的问题,然而,当我们得知了[-2,1]和[-3,4]的最大子数组,是否能在常量时间内计算[-2,1,-3,4]的最大子数组而不是进行一遍线性扫描呢?

如果我们能够做到这一点,合并代价将从 Θ ( n ) \Theta(n) Θ(n)缩减至 Θ ( 1 ) \Theta(1) Θ(1)。将1代入上方的递归树中,我们可以写出一个求和式来计算总代价: ∑ i = 1 log ⁡ 2 n 2 i − 1 = Θ ( n ) \sum_{i=1}^{\log_2 n}2^{i-1}=\Theta(n) i=1log2n2i1=Θ(n)

2 i − 1 2^{i-1} 2i1代表了递归树的每一层的执行时间,递归树的高度为 log ⁡ 2 n \log_2 n log2n。改进合并时的效率,我们就可以得到一个执行时间为 Θ ( n ) \Theta(n) Θ(n)的算法。

让我们再回顾一遍这个事实:跨越了中点的最大子数组,是由左右两个区间(因mid而分开)组成的,我们分别记为A[i…mid]和A[mid+1…j]。而A[i…mid]对于A[low…mid]而言,是“从右端点开始的最大子数组”,如果我们取A[low…mid]的中点为m,把它们再次一分为二,记为A[low…m]和A[m+1…mid],A[low…mid]的从右端点开始的最大子数组,要么是A[m+1…mid]的从右端点开始的最大子数组,要么是A[m+1…mid]的元素之和加上A[low…m]的从左端点开始的最大子数组。

写到这里可能已经有点被绕晕了,总结一下,我们需要维护数组的右最大子数组(从右端点开始的最大子数组,下同)的值,而这个值又依赖于它右半边的子数组的右最大子数组,很明显这个值有递归结构,并且很容易想到,长度为1的数组的这个值为自身。

同理,A[mid+1…j]对A[mid+1…high]而言,是“从左端点开始的最大子数组”,根据对称性,我们很快就能发现这个左最大子数组也具有递归结构。

(想要计算数组A和B合并之后的跨越中点的最大子数组,我们就需要同时知道A的右最大子数组和B的左最大子数组。A的右最大子数组,等于A右半边的右最大子数组或者A左半边的左最大子数组加右半边的所有值。)

看来,想要常量时间内合并它们,我们至少需要维护为每个区间A[left…right]维护3个量:

  • l S u m \text lSum lSum表示left左端点的最大子段和

  • r S u m \text rSum rSum表示right右端点的最大子段和

  • i S u m \text iSum iSum 表示区间总和

当然,如果函数只能返回一个值的话,我们还需要把问题真正的解:最大子数组之值 m S u m \text mSum mSum也一并加上。 m S u m \text mSum mSum的维护方式很容易想象:要么是左半边或右半边的 m S u m \text mSum mSum,要么根据 l S u m \text lSum lSum r S u m \text rSum rSum i S u m \text iSum iSum 来计算出跨越中点的子数组,最大的才是我们想要的值。

这四个值都具有递归结构,而递归的基本情况:长度为1的数组,四个值都等于自身元素之值,至此,递归式建立完成。

使用Java对算法描述如下:

class Solution {
    
    
    public class Status {
    
    
        public int lSum, rSum, mSum, iSum;

        public Status(int lSum, int rSum, int mSum, int iSum) {
    
    
            this.lSum = lSum;
            this.rSum = rSum;
            this.mSum = mSum;
            this.iSum = iSum;
        }
    }

    public int maxSubArray(int[] nums) {
    
    
        return getInfo(nums, 0, nums.length - 1).mSum;
    }

    public Status getInfo(int[] a, int l, int r) {
    
    
        if (l == r) {
    
    
            return new Status(a[l], a[l], a[l], a[l]);
        }
        int m = (l + r) >> 1;
        Status lSub = getInfo(a, l, m);
        Status rSub = getInfo(a, m + 1, r);
        return pushUp(lSub, rSub);
    }

    public Status pushUp(Status l, Status r) {
    
    
        int iSum = l.iSum + r.iSum;
        int lSum = Math.max(l.lSum, l.iSum + r.lSum);
        int rSum = Math.max(r.rSum, r.iSum + l.rSum);
        int mSum = Math.max(Math.max(l.mSum, r.mSum), l.rSum + r.lSum);
        return new Status(lSum, rSum, mSum, iSum);
    }
}

最大子数组的另一个算法描述出自作者:LeetCode-Solution
链接:https://leetcode-cn.com/problems/lian-xu-zi-shu-zu-de-zui-da-he-lcof/solution/lian-xu-zi-shu-zu-de-zui-da-he-by-leetco-tiui/
来源:力扣(LeetCode)

猜你喜欢

转载自blog.csdn.net/chong_lai/article/details/118874498