分治法算法学习(一)——归并排序、求最大子数组和

217 阅读4分钟

最近在学习算法,看了MOOC上北航童咏昕老师的课后收获很多,这篇文章算是我的学习笔记。

什么是分治法?

这个问题其实很好回答,就是将一个大问题拆解成很多个小问题,依次解决每个小问题,最后整理结果合并为大问题的答案,兵法中这叫逐个击破。

哪些时候适合用分治法?

以分治法的三个步骤为思路,首先这个问题要能被拆解,这点大部分问题都满足,因为问题的复杂性往往和问题的规模正相关。其次是能被拆解为若干个相同的子问题,即具有最优子结构性质。最后也是分治法最关键的特点,即每个子问题的解最终能合并为大问题的解。

分治法框架

  1. 分解原问题
  2. 解决子问题
  3. 合并问题解

典型案例——归并排序

首先是分解:

  function mergeSort(arr) {
    let len = arr.length
    if (len < 2) return arr
    let mid = Math.floor(len/2)
    let left=arr.slice(0,mid)
    let right=arr.slice(mid)
    return merge(mergeSort(left),mergeSort(right))
}

可以看到我们这里递归地将原数组分解为两个子数组,既然是递归那么终止条件尤为重要,在本题中为len<2,即被分解为仅有一个元素的数组。其次是解决子问题,也就是merge()函数的编写

function merge(left,right){
    let result=[]
    while(left.length&&right.length){
        if(left[0]>=right[0]){
            result.push(right.shift())
        }
        else {
            result.push(left.shift())
        }
    }
    while(left.length){
        result.push(left.shift())
    }
    while(right.length){
        result.push(right.shift())
    }
    return result
}

因为每个子数组都是被排好序的(当仅有一个元素时也相当于排好序了),所以从每个子数组的头部开始依次比较,这样也就利用了每个子问题的特点。本题因为恰好是排序所以在最关键的合并部分体现的并不是那么明显,那么来看看下一个例子。

求最大子数组和

给定一个数组,求数组中最大的子数组和。

首先这道题暴力求解怎么做?例举出所有子数组和然后比大小,2个循环分别表示子数组头尾列举出所有情况,最后一个循环求和,算法复杂度为O(n^3)。优化一下,在每次子数组头部不变尾巴变长时我们可以利用之前的结果,使用迭代的技巧,这样就减少了一个循环,算法复杂度为O(n^2)。那么还可以更快嘛?这里给出了分治法的求解思路:

本题的难点就在于最后合并的设计,因为是将原本连续的数组拆解了,因此在求解子问题的过程中并没有考虑到跨越分割点的子数组,所以在最后合并问题时我们需要把这种情况重新考虑。(可以说本题的子问题间并不是完全独立的,它们有相交的部分,对于这种情况虽然分治法可以解,但是常常使用动态规划)。

解释下代码中的maxl,maxm,maxr分别代表着左边的最大子数组和,跨越分割点的最大子数组和,右边的最大子数组和。maxCrossSubArray()函数则专门用来求解跨越分割点的最大子数组和。(所以说子问题相关时分治法的设计会变难很多啊!)

function maxSubArray(arr, left, right) {
    let max = 0
    if (left === right) {
        return arr[left]
    } else {
        let mid = Math.floor((left + right) / 2)
        let maxl = maxSubArray(arr, left, mid)
        let maxr = maxSubArray(arr, mid + 1, right)
        let maxm = maxCrossSubArray(arr, left, mid, right)
        max = maxl >= maxm ? maxl : maxm
        max = max >= maxr ? max : maxr
        return max
    }
}

重点考虑如何设计maxCrossSubArray()函数,图中S3就表示跨越分割点的这种情况对应的最大子数组和。

Left我们可以从分割点开始向左遍历,Right则从分割点开始向右遍历,分别求出Left和RIght的最大子数组和后相加即可。

function maxCrossSubArray(arr, left, mid, right) {
    let suml = -Infinity,
        sumr = -Infinity,
        sum = 0
    let templ = 0,
        tempr = 0
    for (let i = mid; i >= left; i--) {
        templ = templ + arr[i]
        suml = templ > suml ? templ : suml
    }
    for (let j = mid + 1; j <= right; j++) {
        tempr = tempr + arr[j]
        if (tempr > sumr) sumr = tempr
    }
    sum = suml + sumr
    return sum
}

可见子问题相关确实令分治法的求解复杂了很多。关于这题其实还可以让我们输出最大子数组,无非是将代码中的sum改为数组用来记录每次添加的元素,随后求和比较,核心思想是没有改变的。

总结

分治法思想很简单,但是对应到实际问题中细节确实非常多,尤其是当子问题相关时,体现在最后合并的设计上需要考虑很多新情况。不过这个思想不仅仅对我们的编程有帮助,在解决很多问题是都可以尝试分而治之。我始终认为学习带给我的提升是多方面的,在锻炼编程能力的同时,实现个人思维方式的升华才是更有意义的。