浅学时间复杂度

178 阅读3分钟

时间复杂度

概念

时间复杂度可以近似理解为代码语句执行的次数

采用大O记法

核心思想是:所有代码的执行时间与代码的执行次数成正比,记作:T(n) = O( f(n) )它表示随着问题规模n的增大,算法执行时间的增长度和f(n)的增长率相同。

  • T(n) 表示算法中语句的执行次数
  • n 表示数据规模的大小,数据规模n不同,代码的执行时间T(n)也会随之而改变
  • f(n) 算法的基本操作(执行次数最多的那条语句)重复执行的次数

在做算法分析时,一般默认为考虑最坏的情况。(下文时间复杂度分析均考虑最坏情况)

常数阶 O(1)

无论代码执行了多少行,只要没有循环等复杂结构,那么这个代码的时间复杂度就是O(1)

var i = 0;
var j = 2;
i++;
j++;
var m = i + j;

线性阶 O(n)

只有一层循环或者递归等,循环或递归内的代码会执行n遍,时间复杂度就是 O(n),这样消耗时间随着N变化而变化

for (let i = 0; i < n; i++) {
  	j = i;
  	j++;
}

平方阶 O(n²)

就是双重for循环,如果外层的是n,内层的是m,那么复杂度就是O(m*n)

for (int i = 0; i < n; i++) {
	console.log("我没事");
}
for (int i = 0; i < n; i++) {
    for (int j = 0; j < n; j++) {
  		Console.log("我没事");
  	}
}
return "我很好";

上述代码总执行次数为n + n^2,如果是多项式,取最高次项,所以这个时间复杂度也是 O(n²)

对数阶 O(logN)

var i = 1; 
// 语句执行一次
while(i < n) {
    i *= 2; 
    // 语句执行logn次
}

每次i*2之后,就距离n更近一步,假设有x个2相乘后大于n,则会退出循环。由于是2^x=n,得到x=log(2)n,所以这个循环的时间复杂度为O(logn)

对于对数阶,由于随着输入规模n的增大,不管底数为多少,他们的增长趋势是一样的,所以我们会忽略底数。log的底数在大O符号的中是省去的。常见的底数为2

线性对数阶 O(nlogN)

for (int j = 0; j < n; j++) {
    int i = 1;
    while (i < n) {
      	i = i * 2;
    }
}

将时间复杂度为**O(logn)**的代码循环n遍

常见排序算法的时间复杂度

冒泡排序

原理:

每一趟只能确定将一个数归位。即第一趟只能确定最后一个数,第二趟确定倒数第二个数... 依此类推,对于n个数进行排序时,需要进行n-1趟操作。

每一趟都需要从第一位开始进行相邻的两个数的比较,将较大的数放后面(以升序为例),重复此步骤,直到最后一个还没归位的数。

代码:

function bubbleSort(array){
    for(var i = 0; i < array.length-1; i++){//控制比较轮次,一共 n-1 趟
        for(var j = 0; j < array.length-1-i; j++){//控制两个挨着的元素进行比较
            if(array[j] > array[j+1]) {
            	// 交换
                var temp = array[j];
                array[j] = array[j+1];
                array[j+1] = temp;
            }
        }
    }
}

时间复杂度分析:

如果需要比较4个数字,排序需要3趟

  • 第一趟比较3次:[1, 2] [2, 3] [3, 4]
  • 第二趟比较2次:[1, 2] [2, 3]
  • 第三趟比较1次:[1, 2]

如果有n个数组需要排序,需要 (n-1) + (n-2) +…+2+1 次,即等差数列求和公式

(n1)+(n2)+...+2+1=n(n1)/2=(1/2)n2(1/2)n(n-1) + (n-2)+...+2+1 = n(n-1)/2 = (1/2)n^2 - (1/2)n

时间复杂度为O(n²)

归并排序

原理:

采用分治法

1.把数组从中间划分成两个子数组; 2.一直递归地把子数组划分成更小的子数组,直到子数组里面只有一个元素 3.依次按照递归的返回顺序,不断地合并排好序的子数组,直到最后把整个数组的顺序排好。

代码:

function mergeSort(arr) {  // 采用自上而下的递归方法
    var len = arr.length;
    if(len < 2) {
        return arr;
    }
    var middle = Math.floor(len / 2),
        left = arr.slice(0, middle),
        right = arr.slice(middle);
    return merge(mergeSort(left), mergeSort(right));
}

function merge(left, right) {
    var result = [];
    while (left.length && right.length) {
        if (left[0] <= right[0]) {
            result.push(left.shift());
        } else {
            result.push(right.shift());
        }
    }

    while (left.length)
        result.push(left.shift());

    while (right.length)
        result.push(right.shift());
    return result;
}

时间复杂度分析:

设:f(n)表示对n个数进行归并排序,n表示对两个子过程结束之后合并的过程,则2f(n2)2f(\frac n2) 表示将n个数分成两部分分别进行归并排序。

可得:f(n)=2f(n2)+nf(n)=2f(\frac n2)+n

推导:f(n2)=2f(n4)+n2f(\frac n2)=2f(\frac n4)+\frac n2 n=n2当n=\frac n2时

f(n4)=2f(n8)+n4f(\frac n4)=2f(\frac n8)+\frac n4 n=n4当n=\frac n时4

​ ......

f(n2m1)=2f(n2m)+n2m1f(\frac n{2^{m-1}})=2f(\frac n{2^m})+\frac n{2^{m-1}} n=n2m1当n=\frac n{2^{m-1}}时

由此可得:

f(n)=2f(n2)+nf(n)=2f(\frac n2)+n

=2(2f(n4)+n2)+n=2*(2f(\frac n4)+\frac n2)+n

=22f(n22)+2n=2^2f(\frac n{2^2})+2n

=22(2f(n8)+n4)+2n=2^2*(2f(\frac n8)+\frac n4)+2n

=23f(n23)+3n=2^3f(\frac n{2^3})+3n

......

=2mf(n2m)+mn=2^mf(\frac n{2^m})+mn

当 m 足够大时(仅剩一个数字时),可使得 n2m=1\frac n{2^m}=1,即m=lognm=logn,代入上式可得

f(n)==2lognf(1)+nlognf(n)==2^{logn}f(1)+n*logn ,其中f(1) = 0

所以归并排序的时间复杂度为O(nlogn)

快速排序

原理:

  1. 从数列中挑出一个元素,称为 "基准";
  2. 重新排序数列,所有元素比基准值小的摆放在基准前面,所有元素比基准值大的摆在基准的后面(相同的数可以到任一边)。在这个分区退出之后,该基准就处于数列的中间位置。这个称为分区操作;
  3. 递归地把小于基准值元素的子数列和大于基准值元素的子数列排序;

代码:

function quickSort(arr, left, right) {
    var len = arr.length,
        partitionIndex,
        left = typeof left != 'number' ? 0 : left,
        right = typeof right != 'number' ? len - 1 : right;

    if (left < right) {
        partitionIndex = partition(arr, left, right);
        quickSort(arr, left, partitionIndex-1);
        quickSort(arr, partitionIndex+1, right);
    }
    return arr;
}

function partition(arr, left ,right) {     // 分区操作
    var pivot = left,                      // 设定基准值(pivot)
        index = pivot + 1;
    for (var i = index; i <= right; i++) {
        if (arr[i] < arr[pivot]) {
            swap(arr, i, index);
            index++;
        }        
    }
    swap(arr, pivot, index - 1);
    return index-1;
}

function swap(arr, i, j) {
    var temp = arr[i];
    arr[i] = arr[j];
    arr[j] = temp;
}
function partition2(arr, low, high) {
  let pivot = arr[low];
  while (low < high) {
    while (low < high && arr[high] > pivot) {
      --high;
    }
    arr[low] = arr[high];
    while (low < high && arr[low] <= pivot) {
      ++low;
    }
    arr[high] = arr[low];
  }
  arr[low] = pivot;
  return low;
}

function quickSort2(arr, low, high) {
  if (low < high) {
    let pivot = partition2(arr, low, high);
    quickSort2(arr, low, pivot - 1);
    quickSort2(arr, pivot + 1, high);
  }
  return arr;
}

时间复杂度分析:

如果足够理想,那我们期望每次都把数组都分成平均的两个部分,如果按照这样的理想情况分下去,我们最终能得到一个完全二叉树。如果排序n个数字,那么这个树的深度就是log(n+1),如果我们将比较n个数的耗时设置为T(n),那我们可以得到如下的公式:

T(n) ≤ 2T(n/2) + n,T(1) = 0  
T(n) ≤ 2(2T(n/4)+n/2) + n = 4T(n/4) + 2n  
T(n) ≤ 4(2T(n/8)+n/4) + 2n = 8T(n/8) + 3n  
......
T(n) ≤ nT(1) + (logn)×n = O(nlogn) 

所以理想情况下时间复杂度为O(nlogn)。

而在最坏的情况下,这个树是一个完全的斜树,只有左半边或者右半边。这时候我们的比较次数就变为i=1n1(ni)=n1+n2+...+1=n(n1)2\sum_{i=1}^{n-1}(n-i) = n-1+n-2+...+1 = \frac {n(n-1)}2

所以最坏的情况下时间复杂度为O(n²)。

堆排序

原理:

堆是一个完全二叉树。

完全二叉树: 二叉树除开最后一层,其他层结点数都达到最大,最后一层的所有结点都集中在左边(左边结点排列满的情况下,右边才能缺失结点)。

分为两种方法:

  1. 大顶堆:每个节点的值都大于或等于其子节点的值,在堆排序算法中用于升序排列,从后往前遍历大顶堆(数组),交换堆顶元素a[0]和当前元素a[i]的位置,将最大值依次放入数组末尾,每交换一次,就要重新调整一下堆,从根节点开始,调整根节点~i-1个节点(数组长度为i),重新生成大顶堆;
  2. 小顶堆:每个节点的值都小于或等于其子节点的值,在堆排序算法中用于降序排列,原理同上;

代码:

var len;    // 因为声明的多个函数都需要数据长度,所以把len设置成为全局变量
function buildMaxHeap(arr) {   // 建立大顶堆
    len = arr.length;
    for (var i = Math.floor(len/2); i >= 0; i--) {
        heapify(arr, i);
    }
}

function heapify(arr, i) {     // 堆调整
    var left = 2 * i + 1,
        right = 2 * i + 2,
        largest = i;

    if (left < len && arr[left] > arr[largest]) {
        largest = left;
    }

    if (right < len && arr[right] > arr[largest]) {
        largest = right;
    }

    if (largest != i) {
        swap(arr, i, largest);
        heapify(arr, largest);
    }
}

function swap(arr, i, j) { // 交换
    var temp = arr[i];
    arr[i] = arr[j];
    arr[j] = temp;
}

function heapSort(arr) {
    buildMaxHeap(arr);

    for (var i = arr.length-1; i > 0; i--) {
        swap(arr, 0, i);
        len--;
        heapify(arr, 0);
    }
    return arr;
}

heapSort(arr);

时间复杂度分析:

堆排序的时间 = 初始化堆过程的时间 + 每次选取最大数后重新建堆的时间

初始化堆:

对于每个非叶子结点,都要将它与它的孩子结点进行比较和交换,顺序是从后向前。

以操作 2 作为基本操作,对每一层都完全铺满的堆进行分析,设元素个数为 n,堆的层数为k,对于完全二叉树有:n = 2^k -1,则易得 k=log(n+1)≈log n,非叶子结点的个数为 2^(k-1)-1

假设每个非叶子结点都需要进行调整(最坏情况),则第 i 层的非叶子结点需要的操作次数为 k-i,第 i 层共有 2^(i-1)个结点,则第 i 层的所有结点所做的操作为 k*2^(i-1)- i*2^(i-1)(二者相乘展开后),共 k-1 层非叶子结点,总的操作次数为i=1k1(k2i1i2i1)\sum_{i=1}^{k-1}(k*2^{i-1}- i*2^{i-1}) 即2^k-k+1,将 k=log(n+1)≈log n 代入,得 n - log n +1,所以,初始化堆的复杂度为 O (n)

选取最大数后调整堆:

假设根节点和排在最后的序号为 m 的叶子结点交换,并进行调整,那么调整的操作次数 = 原来 m 结点所在的层数 = 堆的高度(因为 m 结点在堆的最后)= log m,共n个节点,调整的总次数为i=1k1(k2i1i2i1)\sum_{i=1}^{k-1}(k*2^{i-1}- i*2^{i-1})

化简可得,上式 = log (n-1)! ≈ n*log n,所以,调整堆的复杂度为 O (n*log n)

所以,总体复杂度为 O (n*log n)