算法导论 第二章-算法基础

插入排序

插入排序,对有限元素比较合适的算法。和打牌差不多。

伪代码

INSERTION-SORT[A]
for j ← 2 to length[A] //  for 循环 j = 2, j < 数组A的length 
    do key ← A[j]      //  A[j] 的值 给变量 key
    i ← j-1            //  i = j - 1 
    while i > 0 and A[i] >key // 内层循环 
        do A[i+1] ← A[i]
        i ← i-1
    A[i+1] ← key

循环不变式用来理解算法的正确性
1.初始化:循环的第一次迭代之前,它为真。
2.保持:如果循环的某次迭代之前它为真,那么下次迭代之前它仍为真。
3.终止:在循环终止时,不变式为我们提供了一个有用的性质,该性质有助于证明算法是正确的

比如一个数组 [1, 22, 3, 4, 44, 55], 跟打牌一样,元素 A[1, j-1] 是手里的牌,[j, n] 是桌上的牌

数学归纳法,要证明某一性质正确,必须首先证明基本情况和一个归纳步骤都是正确的。
归纳法中,归纳步骤是无穷使用的。

就插入排序而言,证明一下。
初始化,当j=2,数组[1..j-1],就1个元素,显然是正确的
保持,证明没一次循环都能让循环不变式成立,在外层循环中,让A[j-1], A[j-2],A[j-3]向右移动,找到A[j]的合适位置。
终止,在j>n时,循环结束了,把j替换为n+1,子数组A[1, n]包含了原来A[1,n]的元素,现在排序好了,他就是整个数组,所以这是正确的。

算法的运行时间,是指在特定的输入时,所执行的基本操作数。

  // 插入排序 js版本实现 
  function insertSort(arr) {
    let len = arr.length;
    for (let a = 1; a < len; a++) {
      // 当前值,当前牌
      let cur = arr[a];
      let b = a - 1;
      // 找到一个合适的位置,把cur插进去
      // arr[b] 是排序好的最大的一个,如果这个不大于cur,那就不用排了
      while (b > 0 && cur < arr[b]) {
        arr[b + 1] = arr[b];
        b--;
      }
      arr[b + 1] = cur;
    }
    return arr;
  }

分析算法

  • 分析算法,通常我们想度量的是时间。
  • 我们一般假定是单处理器计算模型 RAM作为实现技术,指令一条一条执行,没有并发操作。
  • RAM模型,包含真实计算机的指令,如算数指令(加减乘除等)、数据移动指令、控制指令,每条这样的指令需要的时间都是常数。
  • 一般来说,算法的时间与输入规模同步增长。所以通常把一个算法的运行时间,描述成输入规模的函数。
  • 输入规模的最佳概念依赖于研究的问题。
  • 一个算法在特定输入上的运行时间,是执行的基本操作数或者步数。

最坏情况和平均情况分析

对于插入排序的分析。通过具体分析,在最优情况,即数组是排序好的情况下,运行时间可表示为

a*n+b

对于最差的情况,是

a * n的平方 + b * n + c 

在一般情况下,我们是考察算法的最坏运行情况。

增长的量级

运行时间的增长率,或者叫做增长的量级,这样我们只考虑公式的最高次项,以为当n很大,低阶项就不重要了
另外,还要忽略最高次项的常数次数。例如,插入排序的最差时间代价是 Θ(n2)
如果一个算法的最坏运行时间比另外一个低,那么我们认为它的效率更高。

算法设计

插入排序使用的是,增量方法,在排好序的子数组插入元素。

分治法

分治策略是,把原问题划分为n个规模较小,结构与原问题相似的子问题。
递归解决子问题,然后合并结果得到原问题的解。
分治模式,在每一层递归上,都有3个步骤。

  1. 分解,把原问题划分为子问题。
  2. 解决,递归解决子问题,子问题足够小则直接解决。
  3. 合并,子问题的解合并为原问题的解。

以下是归并排序的伪代码

MERGE(A, p, q, r)
    n1 = q-p+1
    n2 = r-q
    //let L[1....n1+1] and R[1....n2+1] be new arrays
    for i =1 to n1
         L[i] = A[p+i-1]
    for j=1 to n2
        R[j] = A[q+j]
    L[n1+1] = ∞
    L[n2+1] = ∞
    i=1
    j=1
    for k =p to r
        if L[i]<=R[j]
            A[k] = L[i]
            i = i + 1
        else
            A[k]=R[j]
            j = j+1

MERGE-SORT(A,p,r)
    if p < r
        q =(p+r)/2  //向下取整,不会打那个符号
        MERGE-SORT(A, p, q)
        MERGE-SORT(A, q+1, r)
        MERGE(A, p, q, r)

归并排序的关键是,合并两个已排序好的子序列。通过一个 MERGE(A,p,q,r) 来实现 p <= q <= r
前提是,子数组 A[p, q]A[q+1, r] 是排序好的数组。通过合并这两个排序好的子数组来代替当前
的子数组 A[p,r];

以下是JavaScript的实现,开始并不好理解,可以打开debugger,一步一步的去看,就比较清晰了

function mergeSort(arr, start, end) {
  if (start < end) {
    let m = Math.floor((start + end) / 2);
    mergeSort(arr, start, m);
    mergeSort(arr, m + 1, end);
    return merge(arr, start, m, end);
  }
}

function merge(arr, start, mid, end) {
  /*
    这是原书的代码
    // 创建左子数组
    let n1 = mid - start + 1;
    // 创建一个数组,size是子数组的长度+1
    let L = new Array(n1 + 1);
    for (let i = 0; i < n1; i++) {
      L[i] = arr[start + i];
    }

    // 创建右子数组
    let n2 = end - mid;
    // 创建一个数组,size是子数组的长度+1
    let R = new Array(n2 + 1);
    for (let j = 0; j < n2; j++) {
      R[j] = arr[mid + j + 1];
    }
  */

  // js代码可以很简单的实现上边的功能
  let L = [...arr.slice(start, mid + 1), Infinity];
  let R = [...arr.slice(mid + 1, end + 1), Infinity];
  let i = 0, j = 0;

  for (let k = 0; k < end - start + 1; k++) {
    // debugger
    if (L[i] <= R[j]) {
      arr[start + k] = L[i];
      i++;
    } else {
      arr[start + k] = R[j];
      j++;
    }
  }
  return arr;
}

分治法分析

当一个算法中含有递归调用时,运行时间可用一个递归方程表示。T(n)
如果问题规模足够小,如 n <= c(常量),那么表示为Θ(1)
把原问题分解为a个子问题,每个问题规模是原问题的 1/b, 分解问题是D(n) ,合并问题是C(n)
那么T(n) = aT(n/b) + D(n) + C(n)
归并算法的分析
分解,需要常量时间 D(n) = Θ(1)
解决, 递归解决两个规模是n/2的子问题,时间是2T(n/2)
合并,合并n的元素的子数组,merge时间是Θ(n), 所以C(n) = O(n)
所以带入 T(n) = aT(n/b) + D(n) + C(n) 就是
T(n) = 2T(n/2) + Θ(n) + Θ(n)
T(n) = 2T(n/2) + Θ(n)
我们用常数c代表Θ(1),于是得到 T(n) = 2T(n/2) + cn
递归树总的层数是 lgn+1 ,每一层的代价是 cn
所以 T(n) = cn(lgn+1) = nlgn


huahuadavids
669 声望26 粉丝

nothing to say, but day day up


« 上一篇
重读webpack5