最大子数组问题

本文只是做一个记录,更细致的思路请查看算法导论

最大子数组结构体

typedef struct {
    int low, high, sum;
} SubArray;

暴力求解
计算所有的数组区间的和进而得到最大的子数组,算法复杂度为θ(n²)。这种方法在小规模的数据表现很好,d但是在大规模数据中则很糟糕,但可用作分治算法的改进。实现的思路是先计算从以i为起始的最大子数组,然后从[1..n],[2..n] .. [n..n]中找到最大的子数组。

SubArray MaxSubArray_BruteForce(int low, int high, int A[]) {
    int sum;
    int max_subarray = 0;
    SubArray *S = (SubArray *)calloc((high - low + 2), sizeof(SubArray));

    for (int i = low, k = 0; i <= high; ++i, ++k) {
        S[k].sum = INT_MIN;
        S[k].low = i;
        sum = 0;
        for (int j = i; j <= high; ++j) {   // 计算从以`i`为起始的最大子数组
            sum += A[j];
            if (S[k].sum < sum) {
                S[k].sum = sum;
                S[k].high = j;
            }   
        }   
        if (S[max_subarray].sum <= S[k].sum)    // = 是出现 {0,0,1}的情况精确的计算子数组为[2,2]{1}
            max_subarray = i - low;
    }   

    return S[max_subarray];
}

分治算法
从分治算法的时间分析来看,
\[ T(n)=\begin{cases} \theta(1) & if & n = 1 \\ a\theta(n/a) + \theta(n) & if & n > 1 \end{cases} \]
如果要寻找[low..high]的最大子数组,使用分治的技术意味我们要将数组分为规模尽可能相等的两个子数组(上a为2),寻找出中间的位置mid,再考虑求解子数组[low..mid]和[mid+1..high]。数组[low..high]的最大子数组[i..j]必然是下面三种情况:

  • 完全位于子数组[low..mid]中,因此low <= i <= high <= mid
  • 完全位于子数组[mid+1..high]中,因此mid+1 <= i <= j <= high
  • 跨域了中点mid,因此位于low <= i <= mid <= j <= high

且规定跨越中点的数组 必须包含mid 和 mid + 1

递归的求解子数组[low..mid]和[mid+1..high],减小子问题问题的规模,最后寻找跨越中点的最大子数组,与归并排序的归并过程相似,我们把寻找跨越中点的最大子数组作为子问题的合并过程。简单的逻辑为

  1. 寻找子数组[low..mid]的最大子数组
  2. 寻找子数组[mid+1..high]的最大子数组
  3. 寻找[low..mid mid+1..high]的最大子数组
  4. 返回三个最大子数组中的子数组。注:这里的比较必须要用加上等号,这样可以跳过0更精确的求到最大子数组
SubArray MaxCrossSubArray(int low, int mid, int high, int A[]) {
    int left_sum, right_sum, sum;
    SubArray S;

    left_sum = right_sum = INT_MIN;
    sum = 0;
    for (int i = mid; i >= low; i--) {
        sum += A[i];
        if (left_sum < sum) {
            S.low = i;
            left_sum = sum;
        }
    }   
            
    sum = 0;
    for (int j = mid + 1; j <= high; j++) {
        sum += A[j];
        if (right_sum < sum) {
            S.high = j;
            right_sum = sum;
        }
    }   
            
    S.sum = left_sum + right_sum;
    return S;
}

SubArray MaxSubArray_DivideConquer(int low, int high, int A[]) {
    if (low == high) {
        SubArray S;
        S.low = low;
        S.high = high;
        S.sum = A[low];
        return S;
    }

    int mid = (low + high) / 2;
    SubArray L = MaxSubArray_DivideConquer(low, mid, A);
    SubArray R = MaxSubArray_DivideConquer(mid + 1, high, A);
    SubArray M = MaxCrossSubArray(low, mid, high, A);
    return Max3(L, R, M);
}

改进后的递归算法
前面提到暴力求解虽然在大规模数据表现非常差,但是在小规模的求解时优势很大。当子问题的规模小于某个值n时,我们用暴力算法求解

// 暴力算法和分治算法在 40 左右达到性能交叉点
// 在规模在 10 左右,暴力算法大幅领先分治算法
SubArray MaxSubArray_Synergy(int low, int high, int A[]) {
    if (high - low < 10)
        return MaxSubArray_BruteForce(low, high, A);
    int mid = (low + high) / 2;
    SubArray L = MaxSubArray_Synergy(low, mid, A);
    SubArray R = MaxSubArray_Synergy(mid + 1, high, A);
    SubArray M = MaxCrossSubArray(low, mid, high, A);
    return Max3(L, R, M);
}

线性算法
已知[1..j]的最大子数组,计算[1..j+1]最大子数组的思路:[1..j+1]的最大子数组要么是[1..j]的最大子数组,要么是某个子数组[i..j+1] (1 <= i <= j+1 )。具体实现如注释所述

SubArray MaxSubArray_Linear(int low, int high, int A[]) {
    SubArray S = {-1, -1, INT_MIN};
    int sum = 0;
    int slow = -1;
    for (int i = low; i <= high; ++i) {
        if (sum > 0) {          // 加上A[i]后当前最大子数组为正,中间的非负数项继续保留
            sum += A[i];
        } else {                // 重新寻找最大子数组
            sum = A[i];
            slow = i;
        }

        if (sum > S.sum) {      // 新的最大子数组大于旧最大子数组
            S.low = slow;
            S.high = i;
            S.sum = sum;
        }
    }
    return S;
} 

写代码时碰到的一些问题

  • 刚开始我的递归实现是还有一个SubArray指针的,但是在内存里面真正的SubArray只有一份,每一次计算SubArray变量都在改变。其实我们只需要子数组的下标与和,所以直接在函数内定义等到函数结束栈内存回收也没有关系,因为已经返回。
  • 暴力算法刚开始的i都是从0开始,如果只是单纯的调用不会产生问题,但是当递归算法小规模取调用的情况下就会出现段错误(越界了)
  • 最大子数组大小一样但是几种算法的下标不一样,出现这种的问题是没有把0过滤,如在线性算法中 应该为 if (sum > 0) 而不应该为 if (sum >= 0),递归算法中的Max3比较最大子数组的问题则应该加上等号判断。还有一种是出现这种情况{1, 1, -2, 1, 1}, 这几种算法出现了两种答案[0-1]和[3-4],这个问题目前还没有解决

四种算法的时间复杂度:
\[ \begin{cases} BruteForce & \theta(n^2) & \\ DivideConquer & \theta(nlog(n)) \\ BruteForce+DivideConquer & \theta(nlog(n)) \\ Linear & \theta(n) \end{cases} \]
处理10w(为什么不是更大的数,因为暴力算法太慢了),四种算法的时间大概是

  • 暴力算法 15.12s
  • 简单递归算法 0.15s
  • 优化后的递归算法 0.12s
  • 线性算法忽略不计 0.003s