算法-学习记录

36 阅读9分钟

1、时间复杂度&空间复杂度

  1. 关于时间复杂度,我们说的更多的是通过 O(nlogn) 以及 O(n) 等来衡量.日常工作中编写代码的时候,要努力将代码的时间复杂度维持在 O(nlogn) 以下,要知道凡是超过 n 平方的时间复杂度都是难以接受的。
  2. 空间复杂度比较容易理解,就是对一个算法在运行过程中临时占用存储空间大小的度量。有的算法需要占用的临时工作单元数与解决问题的规模有关,如果规模越大,则占的存储单元越多

1. 时间复杂度

简单理解就是一个算法或是一个程序在运行时,所消耗的时间(或者代码被执行的总次数)。

1.1 常数阶O(1)

不管n等于多少,程序始终只会执行一次,即 T(n) = O(1)

1.2 对数阶O(logn)

i 的值随着 n 成对数增长,读作2为底n的对数,即f(x) = log2n,T(n) = O( log2n),简写为O(logn)

// n = 32 则 i=1,2,4,8,16,32
for (let i = 1; i <= n; i = i * 2) {
    console.log("对数阶:" + n);
}

1.3 线性阶O(n)

n的值为多少,程序就运行多少次,类似函数 y = f(x),即 T(n) = O(n)

1.4 线性对数阶O(nlogn)

线性对数阶O(nlogn)其实非常容易理解,将对数阶O(logn)的代码循环n遍的话,那么它的时间复杂度就是 n * O(logn),也就是了O(nlogn)

for (let m = 1; m <= n; m++) {
    let i = 1;
    while (i < n) {
        i = i * 2;
        console.log("线性对数阶:" + i);
    }
}

1.5 平方阶O(n2)

若 n = 2,则打印4次,若 n = 3,则打印9,即T(n) = O(n2)

以上5种时间复杂度关系为:

image.png

从上图可以得出结论,当x轴n的值越来越大时,y轴耗时的时长为:

O(1) < O(logn) < O(n) < O(nlogn) < O(n2)


2. 空间复杂度

空间复杂度表示的是算法的存储空间和数据之间的关系,即一个算法在运行时,所消耗的空间。

空间复杂度相对于时间复杂度要简单很多,我们只需要掌握常见的:
1. 常数阶O(1): const a = ''
2. 线性阶O(n): const arr = []
3. 平方阶O(n2): const arr = [][]

2. 排序

  1. 比较类排序:通过比较来决定元素间的相对次序,其时间复杂度不能突破 O(nlogn),因此也称为非线性时间比较类排序。
  2. 非比较类排序:不通过比较来决定元素间的相对次序,它可以突破基于比较排序的时间下界,以线性时间运行,因此也称为线性时间非比较类排序。

image.png

非比较类的排序在实际情况中用的比较少,故本讲主要围绕比较类排序展开讲解。其实根据排序的稳定性,也可以分为稳定排序和不稳定排序,例如快速排序就是不稳定的排序、冒泡排序就是稳定的排序

2.1 冒泡排序

  1. 比较所有相邻的两个项,如果第一个比第二个大,则交换它们。元素项向上移动至 正确的顺序,就好像气泡升至表面一样,冒泡排序因此得名。
  2. 不推荐该算法,它的时间复杂度是 O(n2 )
  3. 空间复杂度O(1)
var a = [1, 3, 6, 3, 23, 76, 1, 34, 222, 6, 456, 221];
function bubbleSort(array) {
    const length = array.length
    if (length < 2) return array
    for(let i = 0; i < length; i++) {
        for(let j = 0; j < length - 1 - i; j++) {
            if (array[j] > array[j + 1]) {
                // const temp = array[j + 1]
                // array[j + 1] = array[j]
                // array[j] = temp
                // 巧用解构快速换位
                [array[j + 1], array[j]] = [array[j], array[j + 1]]
            }
        }
    }
    return array
}
bubbleSort(a)

image.png

2.2 选择排序

  1. 找到数据结构中的最小值并 将其放置在第一位,接着找到第二小的值并将其放在第二位,以此类推。
  2. 不推荐该算法,它的时间复杂度是 O(n2 )
  3. 空间复杂度是O(1)
var a = [1, 3, 6, 3, 23, 76, 1, 34, 222, 6, 456, 221];
function selectionSort(array) {
    const length = array.length
    if (length < 2) return array
    for(let i = 0; i < length; i++) {
        for (let j = i + 1; j < length; j++) {
            if (array[j] < array[i]) {
                [array[i], array[j]] = [array[j], array[i]]
            }
        }
    }
    return array
}
selectionSort(a)

image.png

2.3 插入排序

  1. 每次排一个数组项,以此方式构建最后的排序数组
  2. 不推荐该算法,它的时间复杂度是 O(n2 )
  3. 空间复杂度O(1)
  4. 排序小型数组时,此算法比选择排序和冒泡排序性能要好。
// 思路:假定第一项已经排序了。接着待插入的第二项与第一项进行比较(前面的项比待插入项大则向后移一位)这样头两项就已正确排序,接着待插入的第三项与前面的项进行比较,以此类推。
var a = [1, 3, 6, 3, 23, 76, 1, 34, 222, 6, 456, 221];
function insertionSort(array) {
    const length = array.length
    if (length < 2) return array
    for (let i = 1; i < length; i++) {
        let j = i - 1
        // temp为待插入值,从第二项开始
        const temp = array[i]
        while(j>= 0 && array[j] > temp) {
            // 如果待插入值小于前一项的值,将前一项后移一位,直到前一项的值比带插入值小
            // j的位置空出待插入
            array[j + 1] = array[j]
            j--
        }
        // 该后移的都后移完了,空出来一项就属于待插入值,为什么j + 1,因为上面最后又执行了j--
        array[j + 1] = temp
    }
    return array
}
insertionSort(a)

image.png

2.4 归并排序

  1. 将原始数组切分成较小的数组,直到每个小数组只 有一个位置,接着将小数组归并成较大的数组,直到最后只有一个排序完毕的大数组。
  2. 它的时间复杂度是O(nlogn)
  3. 空间复杂度O(n)
// 思路:用二分法将数组递归拆成单项,再按排序结果进行合并
var a = [1, 3, 6, 3, 23, 76, 1, 34, 222, 6, 456, 221];
function mergeSort(array) {
    const length = array.length
    if (length < 2) return array
    const middle = length >> 1 // 二分发切分数组
    const left = mergeSort(array.slice(0, middle))
    const right = mergeSort(array.slice(middle))
    return merge(left, right)
}
function merge(left,right) {
    // 按排序结果进行合并
   let i = 0, j = 0;
   const leftLength = left.length
   const rightLength = right.length
   let result = []
   // 两种特殊情况,左侧都比右侧小,或者左侧都比右侧大
   if (left[leftLength - 1] <= right[0]) {
       return [...left,...right]
   } else if (left[0] >= right[rightLength]) {
       return [...right, ...left]
   } else {
       while(i < leftLength && j < rightLength) {
           if (left[i] < right[j]) {
               result.push(left[i])
               i ++
           } else {
               result.push(right[j])
               j ++
           }
       }
       // 当某一方全部合并后,另一方剩余部分直接合并就可以了
       result = result.concat(i < leftLength ? left.slice(i) : right.slice(j))
   }
   return result
}
mergeSort(a)

image.png

2.5 快速排序

  1. 也使用分而治之的方法,将原始数组 分为较小的数组(但它没有像归并排序那样将它们分割开)
  2. 它的复杂度是O(nlogn)
  3. 是最常用的排序算法
/**
* 思路:
* 1.首先,从数组中选择一个值作为中元(pivot),也就是数组中间的那个值
* 2.创建两个指针(引用),左边一个指向数组第一个值,右边一个指向数组最后一个值。移动左指针直到我们找到一个比中元大的值,接着移动右指针直到找到一个比中元小的值,然后 交换它们,重复这个过程,直到左指针超过了右指针。这个过程将使得比中元小的值都排在中元 之前,而比中元大的值都排在中元之后。这一步叫作划分(partition)操作
* 3.接着,算法对划分后的小数组(较中元小的值组成的子数组,以及较中元大的值组成的子数组)重复之前的两个步骤,直至数组已完全排序
* 4.这种方法排序,空间复杂度为o(n)
*/
var a = [1, 3, 6, 3, 23, 76, 1, 34, 222, 6, 456, 221];
function quickSort(array) {
    const length = array.length
    if (length < 2) return array
    let left = 0, right = length - 1;
    function quick(array, left, right) {
        const index = partition(array, left, right)
        // 通过递归细化数组并按左小右大排序
        if (left < index - 1) {
            quick(array, left, index - 1)
        } 
        if (index < right) {
            quick(array, index, right)
        }
        return array
    }
    // 将数组划分为左右两侧,并获取分界下标
    function partition(array, i, j) {
        // 找到中元
        const middleIdx = (i + j) >> 1
        const middle = array[middleIdx]
        // 这里的时间复杂度为O(n)
        while(i <= j) {
            // 偏移左右指针
            while(array[i] < middle) {
                i++
            }
            while(array[j] > middle) {
                j--
            }
            if (i <= j) {
                // 左侧小,右侧大
                [array[i], array[j]] = [array[j], array[i]]
                i++
                j--
            }
        }
        // i值将数组分为左右大小两侧
        return i
    }
    quick(array, left, right)
    return array
}
quickSort(a)

2.6 sort排序

sort 方法是对数组元素进行排序,默认排序顺序是先将元素转换为字符串,然后再进行排序,先来看一下它的语法: arr.sort([compareFunction])

  1. 如果 compareFunction(a, b)小于 0,那么 a 会被排列到 b 之前;
  2. 如果 compareFunction(a, b)等于 0,a 和 b 的相对位置不变;
  3. 如果 compareFunction(a, b)大于 0,b 会被排列到 a 之前。

1. 底层 sort 源码分析

先大概来梳理一下源码中排序的思路(下面的源码均来自 V8 源码中关于 sort 排序的摘要,地址:V8 源码 sort 排序部分)。 通过研究源码我们先直接看一下结论,如果要排序的元素个数是 n 的时候,那么就会有以下几种情况:

  1. 当 n<=10 时,采用插入排序;

  2. 当 n>10 时,采用三路快速排序;

  3. 10<n <=1000,采用中位数作为哨兵元素;

  4. n>1000,每隔 200~215 个元素挑出一个元素,放到一个新数组中,然后对它排序,找到中间位置的数,以此作为中位数。

接下来,我们看一下官方实现的 sort 排序算法的代码基本结构。

function ArraySort(comparefn) {
    CHECK_OBJECT_COERCIBLE(this, "Array.prototype.sort");
    var array = TO_OBJECT(this);
    var length = TO_LENGTH(array.length);
    return InnerArraySort(array, length, comparefn);

}

function InnerArraySort(array, length, comparefn) {
    // 比较函数未传入
    if (!IS_CALLABLE(comparefn)) {
        comparefn = function (x, y) {
            if (x === y) return 0;
            if (% _IsSmi(x) && % _IsSmi(y)) {
                return % SmiLexicographicCompare(x, y);
            }
            x = TO_STRING(x);
            y = TO_STRING(y);
            if (x == y) return 0;
            else return x < y ? -1 : 1;
        };
    }

    function InsertionSort(a, from, to) {
        // 插入排序
        for (var i = from + 1; i < to; i++) {
            var element = a[i];
            for (var j = i - 1; j >= from; j--) {
                var tmp = a[j];
                var order = comparefn(tmp, element);
                if (order > 0) {
                    a[j + 1] = tmp;
                } else {
                    break;
                }
            }
            a[j + 1] = element;
        }
    }

    function GetThirdIndex(a, from, to) {   // 元素个数大于1000时寻找哨兵元素
        var t_array = new InternalArray();
        var increment = 200 + ((to - from) & 15);
        var j = 0;
        from += 1;
        to -= 1;
        for (var i = from; i < to; i += increment) {
            t_array[j] = [i, a[i]];
            j++;
        }
        t_array.sort(function (a, b) {
            return comparefn(a[1], b[1]);
        });
        var third_index = t_array[t_array.length >> 1][0];
        return third_index;
    }

    function QuickSort(a, from, to) {  // 快速排序实现
        //哨兵位置
        var third_index = 0;
        while (true) {
            if (to - from <= 10) {
                InsertionSort(a, from, to); // 数据量小,使用插入排序,速度较快
                return;
            }
            if (to - from > 1000) {
                third_index = GetThirdIndex(a, from, to);
            } else {
                // 小于1000 直接取中点
                third_index = from + ((to - from) >> 1);
            }
            // 下面开始快排
            var v0 = a[from];
            var v1 = a[to - 1];
            var v2 = a[third_index];
            var c01 = comparefn(v0, v1);
            if (c01 > 0) {
                var tmp = v0;
                v0 = v1;
                v1 = tmp;
            }
            var c02 = comparefn(v0, v2);
            if (c02 >= 0) {
                var tmp = v0;
                v0 = v2;
                v2 = v1;
                v1 = tmp;
            } else {
                var c12 = comparefn(v1, v2);
                if (c12 > 0) {
                    var tmp = v1;
                    v1 = v2;
                    v2 = tmp;
                }
            }
            a[from] = v0;
            a[to - 1] = v2;
            var pivot = v1;
            var low_end = from + 1;
            var high_start = to - 1;
            a[third_index] = a[low_end];
            a[low_end] = pivot;
            partition: for (var i = low_end + 1; i < high_start; i++) {
                var element = a[i];
                var order = comparefn(element, pivot);
                if (order < 0) {
                    a[i] = a[low_end];
                    a[low_end] = element;
                    low_end++;
                } else if (order > 0) {
                    do {
                        high_start--;
                        if (high_start == i) break partition;
                        var top_elem = a[high_start];
                        order = comparefn(top_elem, pivot);
                    } while (order > 0);
                    a[i] = a[high_start];
                    a[high_start] = element;
                    if (order < 0) {
                        element = a[i];
                        a[i] = a[low_end];
                        a[low_end] = element;
                        low_end++;
                    }
                }
            }
            // 快排的核心思路,递归调用快速排序方法
            if (to - high_start < low_end - from) {
                QuickSort(a, high_start, to);
                to = low_end;
            } else {
                QuickSort(a, from, low_end);
                from = high_start;
            }
        }
    }
}

2.总结

image.png 如果当 n 足够小的时候,最好的情况下,插入排序的时间复杂度为 O(n) 要优于快速排序的 O(nlogn),因此就解释了这里当 V8 实现 JS 数组排序算法时,数据量较小的时候会采用插入排序的原因了。