时间复杂度
概念
时间复杂度可以近似理解为代码语句执行的次数
采用大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 次,即等差数列求和公式
时间复杂度为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表示对两个子过程结束之后合并的过程,则 表示将n个数分成两部分分别进行归并排序。
可得:
推导:
......
由此可得:
......
当 m 足够大时(仅剩一个数字时),可使得 ,即,代入上式可得
,其中f(1) = 0
所以归并排序的时间复杂度为O(nlogn)
快速排序
原理:
- 从数列中挑出一个元素,称为 "基准";
- 重新排序数列,所有元素比基准值小的摆放在基准前面,所有元素比基准值大的摆在基准的后面(相同的数可以到任一边)。在这个分区退出之后,该基准就处于数列的中间位置。这个称为分区操作;
- 递归地把小于基准值元素的子数列和大于基准值元素的子数列排序;
代码:
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)。
而在最坏的情况下,这个树是一个完全的斜树,只有左半边或者右半边。这时候我们的比较次数就变为
所以最坏的情况下时间复杂度为O(n²)。
堆排序
原理:
堆是一个完全二叉树。
完全二叉树: 二叉树除开最后一层,其他层结点数都达到最大,最后一层的所有结点都集中在左边(左边结点排列满的情况下,右边才能缺失结点)。
分为两种方法:
- 大顶堆:每个节点的值都大于或等于其子节点的值,在堆排序算法中用于升序排列,从后往前遍历大顶堆(数组),交换堆顶元素
a[0]和当前元素a[i]的位置,将最大值依次放入数组末尾,每交换一次,就要重新调整一下堆,从根节点开始,调整根节点~i-1个节点(数组长度为i),重新生成大顶堆; - 小顶堆:每个节点的值都小于或等于其子节点的值,在堆排序算法中用于降序排列,原理同上;
代码:
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 层非叶子结点,总的操作次数为
即2^k-k+1,将 k=log(n+1)≈log n 代入,得 n - log n +1,所以,初始化堆的复杂度为 O (n)
选取最大数后调整堆:
假设根节点和排在最后的序号为 m 的叶子结点交换,并进行调整,那么调整的操作次数 = 原来 m 结点所在的层数 = 堆的高度(因为 m 结点在堆的最后)= log m,共n个节点,调整的总次数为
化简可得,上式 = log (n-1)! ≈ n*log n,所以,调整堆的复杂度为 O (n*log n)
所以,总体复杂度为 O (n*log n)