算法导论知识梳理(一):函数增长符号及分治法

1,773 阅读8分钟

前言

之所以想写这一系列文章,主要是因为最近有在看算法导论。为什么要看这本书,虽然有些人会认为对前端来说,数据结构和算法可能并不是那么重要。其实不然,这一块的知识其实应该算是重中之重,只有真正理解这一块知识,才能提升我们的核心竞争力。很多时候,我们与别人的差距可能就差在这些通用知识上,而不仅仅是某一块具体的知识领域。

根据我目前的阅读情况来说,对其中的内容其实也都处于一种一知半解的状态吧,所以才打算通过写文章的方式来进行巩固和梳理,也算是一种复习吧。当然这本书我也还没看完,纯粹就是一边看一边写,所以更新进度完全随缘。

渐近记号Θ、Ο、o、Ω、ω

在开始之前,先来了解以下几个函数增长的渐进记号。渐近记号值得是对于给定的函数g(n),用渐近记号来表示以下函数的集合:

渐近紧确界记号:Θ(big-theta)

Θ(g(n))={ f(n):存在正常量c1、c2和n0,使的对所有n >= n0,有0 <= c1g(n) <= f(n) <= c2g(n) }

渐近紧确上界记号:Ο(big-order)

Ο(g(n))={ f(n):存在正常量c和n0,使的对所有n >= n0,有0 <= f(n) <= cg(n) }

非渐近紧确上界记号:o(small-order)

o(g(n))={ f(n):对任意正常量c > 0,存在常量n0 > 0,使的对所有n >= n0,有0 <= f(n) < cg(n) }

渐近紧确下界记号:Ω(big-omege)

Ω(g(n))={ f(n):存在正常量c和n0,使的对所有n >= n0,有0 <= cg(n) <= f(n) }

非渐近紧确下界记号:ω(small-omege)

ω(g(n))={ f(n):对任意正常量c > 0,存在常量n0 > 0,使的对所有n >= n0,有0 <= cg(n) < f(n) }

说明

更直观地,可以通过图来查看

1-1

我们可以通过下面的表格来加深理解

记号 含义 简单理解
Θ 渐近紧确 相当于"="
Ο 渐近紧确上界 相当于"<="
o 非渐近紧确上界 相当于"<"
Ω 渐近紧确下界 相当于">="
ω 非渐近紧确下界 相当于">"

在平时,我们更多地探讨的其实是O,因为在大多数算法的复杂度中,我们只需要关心其上界,而不是其下界,而对于Θ而言,我们需要同时证明其上界和下界。(PS:这句话的意思,指的并不是算法的最好情况和最差情况,而是对于某一具体情况下,其时间复杂度T(n)是一个多项式,而我们只关心多项式的上界,例如:在平均情况下,某算法的时间复杂度计算出来为T(n)=c1n2+c2n+c0,则其复杂度为O(n2),当然也可以用Θ(n2)表示其复杂度)

分治法

分治法即将原问题分解为几个规模较小,但类似于原问题的子问题,递归地求解这些子问题,然后再合并这些子问题的解来建立原问题的解。一般的,其有以下三个步骤:

  1. 分解原问题为若干子问题,这些子问题是原问题的规模较小的实例
  2. 解决这些子问题,递归地求解各子问题,然而,若子问题的规模足够小,则直接求解
  3. 合并这些子问题的解,组合成原问题的解

使用分治法求解最大子数组

比较典型的的例子有归并排序和快速排序,关于这两个排序,下面将会提及。这里,我先从另一个经典的例子开始:给定一个数组A=[13,-3,-25,20,-3,-16,-23,18,20,-7,12,-5,-22,15,-4,7],求解其最大子数组Max。(最大子数组Max定义:Max为A的子数组,并且对于任意A的子数组a来说,都有sum(Max) >= sum(a)。数组A必定是同时包含正负值的,因为如果全是正值或全是负值,此问题将毫无意义。映射到现世生活中的一个典型的例子就是给定某一个时间段内股票的涨跌,计算在该时间段内如何买入卖出才能获得最大收益。)

假定我们需要寻找a[low...high]的最大子数组,使用分治法就要求我们将其分解为两个规模尽量相等的子数组,假设mid为其中央位置。那么其最大子数组Max必然属于以下情况之一:

  1. 完全位于子数组[low...mid]中
  2. 完全位于子数组[mid+1...high]中
  3. 跨越了mid,一部分位于[low...mid]中,另一部分位于[mid+1...high]

那么对于情况1和2而言,其实还是求解最大子数组,只是规模更小而已,所以递归这一过程即可。那么对于情况3来说,相对左部来说,只需从mid开始,逐级递减至low,求出最大值;右部则相反。然后将左右部的结果合并即可。

js代码:

const arr = [13,-3,-25,20,-3,-16,-23,18,20,-7,12,-5,-22,15,-4,7];
// 跨越左右数组
function findCrossMaxSubArr(arr, low, mid ,high) {
  let leftMax = rightMax = sum = leftIndex = rightIndex = 0;
  // 求左部最大
  for(let i = mid; i >= low; i--) {
    sum += arr[i];
    if(sum >= leftMax) {
      leftMax = sum;
      leftIndex = i;
    }
  }
  // 求右部最大
  sum = 0;
  for(let i = mid + 1; i <= high; i++) {
    sum += arr[i];
    if(sum >= rightMax) {
      rightMax = sum;
      rightIndex = i;
    }
  }
  return {
    sum: leftMax + rightMax,
    leftIndex,
    rightIndex
  };
}
function findMaxSubArr(arr, low, high) {
  if(low === high) return {
    sum: arr[low],
    leftIndex: low,
    rightIndex: high
  };
  const mid = Math.floor((low + high) / 2);
  // 不跨越左右数组时,直接递归即可
  const leftMax = findMaxSubArr(arr, low, mid);
  const rightMax = findMaxSubArr(arr, mid + 1, high);
  // 跨越左右数组时,求此情况下的最大值
  const corssMax = findCrossMaxSubArr(arr, low, mid, high);
  // 找出三种情况下的最大值
  let max = leftMax;
  if(rightMax.sum > max.sum) max = rightMax;
  if(corssMax.sum > max.sum) max = corssMax;
  return max;
}
// 返回
// {
//   leftIndex: 7,
//   rightIndex: 10,
//   sum: 43
// }
console.log(findMaxSubArr(arr, 0, 15))

接下来,就是对复杂度进行分析。但是因为完整的过程比较复杂,所以这里就不进行分析,有兴趣了解的可以去查阅一下完整的推导过程。以下为我个人的简单理解:首先,因为二分策略,所以二分的层级为log2n,然后比较的操作次数为cn次,所以大致估算其复杂度为O(nlogn)。

其他算法求解最大子数组

单单就这一问题而言,大多数人可能想到的第一种方法就是两层循环暴力求解,但是这样复杂度就是O(n2)。而上面所讲到的分治法的复杂度只需O(nlogn),由此可见分治法的优势。但是,除了分治法以外,还可以通过动态规划解决这一问题,其时间复杂度仅需O(n),其对应的状态转移方程为:dp[i] = Max(dp[i-1] + A[i], A[i]),dp[i]表示以arr[i]为结尾的最大连续子数组之和,只需求dp[i]的最大值即可。这一部分打算到后面动态规划的章节再详细讲解,这里先贴一下代码。

// 如需获取下标等信息,只需在此基础上扩展
function findMaxSubArr(arr) {
  const dp = [];
  for(let i = 0; i < arr.length; i++) {
    if(i === 0) dp[i] = arr[i];
    else dp[i] = Math.max(dp[i - 1] + arr[i], arr[i]);
  }
  return Math.max(...dp);
}

此算法时间复杂度为O(n),空间复杂度也为O(n)

此外,还有另一种联机算法也能处理该问题。个人感觉应该算是上面算法的优化了空间复杂度的版本。其时间复杂度为O(n),空间复杂度为O(1)

联机算法是在任意时刻算法对要操作的数据只读入(扫描)一次,一旦被读入并处理,它就不需要在被记忆了。而在此处理过程中算法能对它已经读入的数据立即给出相应子序列问题的正确答案。

function findMaxSubArr(arr) {
  // curSum表示到当前i为止的累加和
  let maxSum = curSum = 0;
  for(let i = 0; i < arr.length; i++) {
    curSum += arr[i];
    if(curSum > maxSum) maxSum = curSum;
    // 如果累加和出现小于0的情况,
    // 则最大和子序列肯定不可能包含前面的元素,  
    // 这时将累加和置0,从下个元素重新开始累加
    else if(curSum < 0) curSum = 0;
  }
  return maxSum;
}

此章内容先到这里为止,觉得有用的话麻烦点个赞呦,谢谢。下一节内容预告:比较排序算法及其下界。