Coder Social home page Coder Social logo

blog's Introduction

blog's People

Contributors

checkson avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

blog's Issues

JavaScript 字符串匹配算法

前言

字符串匹配算法,在日常开发中也常被频繁用到。当然,我们可以用正则匹配来完成字符串匹配,但是,学习和理解相关的字符串匹配算法,对于我们技术成长还是有很多好处的。

定义

字符串匹配算法,是在实际工程中经常遇到的问题,也是各大公司笔试面试的常考题目。此算法通常输入为原字符串(string)和子串(pattern),要求返回子串在原字符串中首次出现的位置。

1. BF算法

BF

BF(Brute Force),暴力检索法是最好想到的算法,也最好实现。首先将原字符串和子串左端对齐,逐一比较;如果第一个字符不能匹配,则子串向后移动一位继续比较;如果第一个字符匹配,则继续比较后续字符,直至全部匹配。 时间复杂度:O(nm)。其中 n 为原字符串长度,m 为子串长度。

function BF (src, dest) {
    var len1 = src.length,
        len2 = dest.length;
    var i = 0,
        j = 0;
    while (i < len1 && j < len2) {
        if (src[i] === dest[j]) {
            i++;
            j++;
        } else {
            i = i - j + 1;
            j = 0;
        }
    }
    if (j === len2) {
        return i - j;
    }
    return -1;
}

2. RK算法

RK(Robin-Karp),哈希检索算法是对BF算法的一个改进:在BF算法中,每一个字符都需要进行比较,并且当我们发现首字符匹配时仍然需要比较剩余的所有字符。而在RK算法中,就尝试只进行一次比较来判定两者是否相等。 RK算法也可以进行多模式匹配,在论文查重等实际应用中一般都是使用此算法。

首先计算子串的HASH值,之后分别取原字符串中子串长度的字符串计算HASH值,比较两者是否相等:如果HASH值不同,则两者必定不匹配,如果相同,由于哈希冲突存在,也需要按照BF算法再次判定。

RK

按照此例子,首先计算子串“DEF”Hash值为 Hd,之后从原字符串中依次取长度为3的字符串 “ABC”、“BCD”、“CDE”、“DEF”计算Hash值,分别为Ha、Hb、Hc、Hd,当 Hd 相等时,仍然要比较一次子串“DEF”和原字符串“DEF”是否一致。 时间复杂度:O(nm)(实际应用中往往较快,期望时间为O(n+m))。

要实现RK算法,最重要的是怎么去选取Hash函数。这里我们选用前面章节 《JavaScript 散列》 中提到的“除留余数法”。

function hash (data) {
    var total = 0;
    for (var i = 0, len = data.length; i < len; i++) {
        total += 37 * total + data.charCodeAt(i);
    }
    total = total % 144451;
    return parseInt(total);
}

function isMatch (str, dest) {
    if (str.length !== dest.length) {
        return false;
    }
    for (var i = 0; i < str.length; i++) {
        if (str[i] !== dest[i]) {
            return false;
        }
    }
    return true;
}

function RK (src, dest) {
    if (!src || !dest) {
        retunr -1;
    }
    var len1 = src.length,
        len2 = dest.length;
    var destHash = hash(dest),
        index = -1;
    for (var i = 0; i <= len1 - len2; i++) {
        var subStr = src.substr(i, len2);
        if (hash(subStr) === destHash && isMatch(subStr, dest)) {
            index = i;
            break;
        }
    }
    return index;
}

3. KMP算法

KMP(Knuth-Morris-Pratt)算法,是字符串匹配最经典的算法之一,也是各大教科书上的看家绝学,曾被投票选为当今世界最伟大的十大算法之一,阮一峰老师也曾为KMP算法写过一篇博客:《字符串匹配的KMP算法》;但是这个算法被普遍认为隐晦难懂,而且十分难以实现。下面,我就不对KMP展开解释了,因为阮一峰老师的博客解释得足以通俗易懂。

在完成KMP算法之前,我们要解决最核心的问题是:部分匹配表的生成。部分匹配表,通俗点理解是,对于匹配串(dest)中所有字串的前缀和后缀匹配个数的分析。

function getNext (str) {
    var res = [];
    var k = 0;
    for (var i = 0, len = str.length; i < len; i++) {
        if (i === 0) {
            res.push(0);
            continue;
        }
        while (k > 0 && str[i] !== str[k]) {
            k = res[k - 1];
        }
        if (str[i] === str[k]) {
            k++;
        }
        res[i] = k;
    }
    return res;
}

这段代码的实现是用了DP(动态规划)**,比较难理解。图例解释会比较直观,以字符串 “ABCDABD”为例:

KMP

KMP算法实现

function KMP (src, dest) {
    var next = getNext(dest);
    var len1 = src.length,
        len2 = dest.length;
    var i = 0,
        j = 0;
    while (i < len1 && j < len2) {
        if (src[i] === dest[j]) {
            i++;
            j++;
        } else {
            j = next[j - 1] || 0;
            i = i + (j > 0 ? 0 : 1);
        }
    }
    if (j === len2) {
        return i - j;
    }
    return -1;
}

4. BM 算法

前面提到的KMP算法,并不是效率最高的算法,实际采用并不多。各种文本编辑器的"查找"功能(Ctrl+F),大多采用的是Boyer-Moore算法

Boyer-Moore算法不仅效率高,而且构思巧妙,容易理解。推荐阮一峰老师的博客教程《字符串匹配的Boyer-Moore算法》 来辅助大家理解,这里不展开讨论。

BM算法实现是较其他匹配算法复杂,它像是KMP算法和Sunday算法的结合体。

5. Sunday算法

BM算法并不是效率最高的算法,比它更快、更容易理解的还有Sunday算法,它有点像BM算法的删减版。

Sunday

结合以上图例,可以用一句话来概括:当原字符串(src)和待查找字符串(dest)不匹配时,只需判断原字符串中下一个字符(THIS单词后的空格)是否出现在待查找字符串(dest)中;若存在,按照从右到左最先出现的位置偏移;若不存在,整体偏移 dest 的长度。

function getMoveLengthObj (str) {
    var resObj = {},
        len = str.length;
    for (var i = 0; i < len; i++) {
        resObj[str[i]] = len - i;
    }
    return resObj;
}

function Sunday (src, dest) {
    var moveObj = getMoveLengthObj(dest);
    var len1 = src.length,
        len2 = dest.length;
    var i = 0,
        j = 0;
    while (i < len1 && j < len2) {
        if (src[i] === dest[j]) {
            i++;
            j++;
        } else {
            i = i - j;
            var offset = moveObj[src[i + len2]];
            if (offset) {
                i += offset;
            } else {
                i += len2;
            }
            j = 0;
        }
    }
    if (j === len2) {
        return i - j;
    }
    return -1;
}

算法性能对比

名字 空间复杂度 最好时间复杂度 最差时间复杂度
BF算法 T(1) O(nm) O(nm)
RK算法 T(1) O(n + m) O(nm)
KMP算法 T(m) O(n + m) O(nm)
BM 算法 T(2m) O(n) O(nm)
Sunday算法 T(m) O(n) O(nm)

除上述字符串匹配算法外,还有一种更快的算法:

参考链接

字符串匹配算法综述
BF、KMP、BM、Sunday算法讲解
Flashtext:大规模数据清洗的利器

ES6 对象代理

前言

对象代理在日常开发中并不常用,因为它是针对编程语言进行编程,属于一种“元编程”。换句话来说,下面介绍的 Proxy 构造器 和 Reflect 内置对象,都可以针对 JavaScript 这门编程语言相关的原生功能,进行自定义新增、扩展、修改、删除、屏蔽等操作。

vue 3.0 将引入ES6中的 Proxy 替代原有ES5的 Object.defineProperty 方法,来实现数据双向绑定等功能。

Proxy

Proxy 从字面上可以理解为 “代理”,更准确得来说,它对目标对象的属性访问、设置,或者函数调用等操作进行拦截(处理)。

语法

var proxy = new Proxy(target, handler);

Proxy 构造器接收两个参数,分别是 targethandle。其中 target 参数是目标(被代理/被拦截)对象,而 handler 参数也是对象,用来定制拦截(代理)行为。

实例1

我们先看最简单的拦截对象属性读取的例子:

// 普通对象
var obj = { foo: 0 }
// 代理对象
var proxy = new Proxy(obj, {
    get (target, key, receiver) {
        return 1;
    }
});
console.log(obj.foo);   // 0
console.log(obj.bar);   // undefined
console.log(proxy.foo); // 1
console.log(proxy.bar); // 1

obj 是普通对象,获取不存在的属性,会返回 undefinedproxy 是代理对象,它对属性获取进行拦截,无论获取什么属性(是否存在),都返回 1。

实例2

接着,我们再看看对象的 setget 方法进行拦截。

var proxy = new Proxy({}, {
    get (target, key, receiver) {
        console.log(`Getting ${key}!`);
        return Reflect.get(target, key, receiver);
    },
    set (target, key, value, receiver) {
        console.log(`Setting ${key},Value is ${value}!`);
        return Reflect.set(target, key, value, receiver);
    }
});

proxy.count = 1;
proxy.count++;

// 输出
// Setting count,Value is 1!
// Getting count!
// Setting count,Value is 2!

这里,我们代理对象 proxy 对属性的设置和获取(对应着ES5中的set/get的访问器)都进行了拦截,都会自动输出相应的信息。Reflect 是ES6中的一个内置对象,类似于 Math,后面将详细介绍。

实例3

代理对象会对目标对象进行“浅拷贝”,相互影响。

var target = { a: 1, b: 2 };
var proxy = new Proxy(target, {
    get (target, key, receiver) {
        console.log(`Getting ${key}!`);
        return Reflect.get(target, key, receiver);
    },
    set (target, key, value, receiver) {
        console.log(`Setting ${key},Value is ${value}!`);
        return Reflect.set(target, key, value, receiver);
    }
});

proxy.a++;
console.log(target.a);
target.c = 3;
console.log(proxy.c);

// 输出
// Getting a!
// Setting a,Value is 2!
// 2
// Getting c!
// 3

根据以上的代码,我们可以发现,代理对象 proxy 只是对 目标对象 targetsetget 操作进行拦截,输出相应的信息,并未改变原始对象数据的存取。

实例4

Proxy 实例也可以作为其他对象的原型对象。

var proxy = new Proxy({}, {
  get: function(target, property) {
    return 35;
  }
});

var obj = Object.create(proxy);
obj.time // 35

上面代码中,proxy 对象是 obj 对象的原型,obj对象本身并没有 time 属性,所以根据原型链,会在 proxy 对象上读取该属性,导致被拦截。

Proxy 支持 13 种拦截操作:

  • get(target, propKey, receiver):拦截对象属性的读取,比如 proxy.fooproxy['foo']
  • set(target, propKey, value, receiver):拦截对象属性的设置,比如 proxy.foo = vproxy['foo'] = v,返回一个布尔值。
  • has(target, propKey):拦截 propKey in proxy 的操作,返回一个布尔值。
  • deleteProperty(target, propKey):拦截 delete proxy[propKey] 的操作,返回一个布尔值。
  • ownKeys(target):拦截 Object.getOwnPropertyNames(proxy)Object.getOwnPropertySymbols(proxy)Object.keys(proxy)for...in循环,返回一个数组。该方法返回目标对象所有自身的属性的属性名,而 Object.keys() 的返回结果仅包括目标对象自身的可遍历属性。
  • getOwnPropertyDescriptor(target, propKey):拦截 Object.getOwnPropertyDescriptor(proxy, propKey),返回属性的描述对象。
  • defineProperty(target, propKey, propDesc):拦截Object.defineProperty(proxy, propKey, propDesc)Object.defineProperties(proxy, propDescs),返回一个布尔值。
  • preventExtensions(target):拦截 Object.preventExtensions(proxy),返回一个布尔值。
  • getPrototypeOf(target):拦截 Object.getPrototypeOf(proxy),返回一个对象。
  • isExtensible(target):拦截 Object.isExtensible(proxy),返回一个布尔值。
  • setPrototypeOf(target, proto):拦截 Object.setPrototypeOf(proxy, proto),返回一个布尔值。如果目标对象是函数,那么还有两种额外操作可以拦截。
  • apply(target, object, args):拦截 Proxy 实例作为函数调用的操作,比如proxy(...args)proxy.call(object, ...args)proxy.apply(...)
  • construct(target, args):拦截 Proxy 实例作为构造函数调用的操作,比如new proxy(...args)

Reflect

Reflect 从字面上可以理解为 “反射”,C# 也有类似的反射操作。它包含了对象语言内部的方法,一共有13种静态方法,与 Proxy 中的 hanlder参数支持的方法一一对应。Proxy 相当于去修改设置对象的属性行为,而 Reflect 则是获取对象的这些行为。

设计目的

1) 将 Object 对象的一些明显属于语言内部的方法(比如 Object.defineProperty),放到 Reflect 对象上。现阶段,某些方法同时在 ObjectReflect 对象上部署,未来的新方法将只部署在 Reflect 对象上。也就是说,从 Reflect 对象上可以拿到语言内部的方法。

2)修改某些 Object 方法的返回结果,让其变得更合理。比如,Object.defineProperty(obj, name, desc) 在无法定义属性时,会抛出一个错误,而 Reflect.defineProperty(obj, name, desc) 则会返回false

// 老写法
try {
  Object.defineProperty(target, property, attributes);
  // success
} catch (e) {
  // failure
}

// 新写法
if (Reflect.defineProperty(target, property, attributes)) {
  // success
} else {
  // failure
}

3) 让 Object 操作都变成函数行为。某些 Object 操作是命令式,比如name in objdelete obj[name],而 Reflect.has(obj, name)Reflect.deleteProperty(obj, name) 让它们变成了函数行为。

// 老写法
'assign' in Object // true
delete object.foo // true

// 新写法
Reflect.has(Object, 'assign') // true
Reflect.deleteProperty(object, 'foo') // true

4)Reflect 对象的方法与 Proxy 对象的方法一一对应,只要是 Proxy 对象的方法,就能在 Reflect 对象上找到对应的方法。这就让 Proxy 对象可以方便地调用对应的 Reflect方法,完成默认行为,作为修改行为的基础。也就是说,不管 Proxy 怎么修改默认行为,你总可以在 Reflect 上获取默认行为。

var proxy = new Proxy({}, {
    get (target, key, receiver) {
        console.log(`Setting ${key}!`);
        Reflect.set(target, key, receiver);
    }
});

Reflect 具有 13 个静态方法,暂时没有实例属性以及实例方法,详情可点击以下参考链接学习。

参考链接

LeetCode(力扣)答案解析(八)

230. 二叉搜索树中第K小的元素

给定一个二叉搜索树,编写一个函数 kthSmallest 来查找其中第 k 个最小的元素。

说明:

你可以假设 k 总是有效的,1 ≤ k ≤ 二叉搜索树元素个数。

示例1:

输入: root = [3,1,4,null,2], k = 1
   3
  / \
 1   4
  \
   2
输出: 1

示例2:

输入: root = [5,3,6,2,4,null,null,1], k = 3
       5
      / \
     3   6
    / \
   2   4
  /
 1
输出: 3

进阶:

如果二叉搜索树经常被修改(插入/删除操作)并且你需要频繁地查找第 k 小的值,你将如何优化 kthSmallest 函数?

解法一(深度优先搜索 + 中序遍历)

/**
 * Definition for a binary tree node.
 * function TreeNode(val) {
 *     this.val = val;
 *     this.left = this.right = null;
 * }
 */
/**
 * @param {TreeNode} root
 * @param {number} k
 * @return {number}
 */
var kthSmallest = function(root, k) {
     var arr = [],
        st = [],
        p = root;
    while (p != null || st.length != 0) {
        while(p != null){
          st.push(p);
          p = p.left;
        }
        p = st.pop();
        arr.push(p.val);
        p = p.right;
    }
    return arr[k - 1];
};

解法二(递归)

/**
 * Definition for a binary tree node.
 * function TreeNode(val) {
 *     this.val = val;
 *     this.left = this.right = null;
 * }
 */
/**
 * @param {TreeNode} root
 * @param {number} k
 * @return {number}
 */
var kthSmallest = function(root, k) {
    var list = [];
    order(root,list);
    return list[k-1];
};
function order (root, list) {
    if (!root) {
        return;
    }
    order(root.left, list);
    list.push(root.val);
    order(root.right, list);
}

解析:

无论是递归方法还是非递归方法,其基本思路都是通过中序遍历搜索二叉树后,得到了从小到大的序列,然后直接取出下标为 k - 1 的元素。

43. 字符串相乘

给定两个以字符串形式表示的非负整数 num1 和 num2,返回 num1 和 num2 的乘积,它们的乘积也表示为字符串形式。

示例1:

输入: num1 = "2", num2 = "3"
输出: "6"

示例2:

输入: num1 = "123", num2 = "456"
输出: "56088"

说明:

  • num1 和 num2 的长度小于110。
  • num1 和 num2 只包含数字 0-9。
  • num1 和 num2 均不以零开头,除非是数字 0 本身。
  • 不能使用任何标准库的大数类型(比如 BigInteger)或直接将输入转换为整数来处理。

题解:

/**
 * @param {string} num1
 * @param {string} num2
 * @return {string}
 */
var multiply = function(num1, num2) {
    var arr1 = num1.split('').reverse(),
        arr2 = num2.split('').reverse();
    var res = [],
        tmp = 0;
    for (var i = 0, len1 = arr1.length; i < len1; i++) {
        for (var j = 0, len2 = arr2.length; j < len2; j++) {
            var a = parseInt(arr1[i]),
                b = parseInt(arr2[j]),
                multi = a * b,
                val = parseInt(res[i + j] || 0);
            val += (multi + tmp);
            if (typeof res[i + j] === 'undefined') {
                res.push(val % 10 + '');
            } else {
                res[i + j] = (val % 10 + '');
            }
            tmp = parseInt(val / 10);
        }
        if (tmp) {
            res.push(tmp + '');
            tmp = 0;
        }
    }
    // 去除多余的0
    var isValid = true;
    for (var i = res.length - 1; i >= 0; i--) {
        if (i === 0) {
            continue;
        } else if (res[i] === '0' && isValid) {
            res.splice(i, 1);
        } else if (res[i] !== '0') {
            isValid = false;
        }
    }
    return res.reverse().join('');
};

2. 两数相加

给出两个 非空 的链表用来表示两个非负的整数。其中,它们各自的位数是按照 逆序 的方式存储的,并且它们的每个节点只能存储 一位 数字。

如果,我们将这两个数相加起来,则会返回一个新的链表来表示它们的和。

您可以假设除了数字 0 之外,这两个数都不会以 0 开头。

示例:

输入:(2 -> 4 -> 3) + (5 -> 6 -> 4)
输出:7 -> 0 -> 8
原因:342 + 465 = 807

题解:

/**
 * Definition for singly-linked list.
 * function ListNode(val) {
 *     this.val = val;
 *     this.next = null;
 * }
 */
/**
 * @param {ListNode} l1
 * @param {ListNode} l2
 * @return {ListNode}
 */
var addTwoNumbers = function(l1, l2) {
    if (!l1) return l2;
    if (!l2) return l1;
    var ans = null,
        trail = null,
        node = null,
        tmp = 0;
    while (l1 != null || l2 != null) {
        var val = tmp;
        if (l1 != null) {
            val += l1.val;
            l1 = l1.next;
        }
        if (l2 != null) {
            val += l2.val;
            l2 = l2.next;
        }
        var node = new ListNode(val % 10);
        tmp = parseInt(val / 10);
        if (ans) {
            trail.next = node;
            trail = node;
        } else {
            ans = node;
            trail = node;
        }
    }
    if (tmp) {
        node = new ListNode(tmp);
        trail.next = node;
    }
    return ans;
};

JavaScript 递归

前言

递归,对于我们很多人来说,并不会陌生。它很早就出现在《算法与数据结构》教科书上,并广泛应用在生产环境中。

定义

程序调用自身的编程技巧称为递归(recursion)。更具体来讲,一个函数(方法)直接调用自身,或者间接调用自身的过程,我们称之为递归

  • 优点:递归策略只需少量的程序就可描述出解题过程所需要的多次重复计算,大大地减少了程序的代码量。

  • 缺点:递归在很多高级语言中还没得到很好的优化,使用不当或者滥用递归,很容易会让程序调用栈溢出,或者运算时间过长,甚至导致程序崩溃。

注意,递归函数必须具有终止条件,不能无限递归,这样就同等于“死循环“。

实例

1. 求解前n项正整数和

这道题的常用解法,我们可以用一个简单的循环完成。

function sum (n) {
    var res = 0;
    for (var i = 0; i <= n; i++) {
        res += i;
    }
    return res;
}

如果换成递归解法话,解法一般如下:

function sum (n) {
    if (n <= 0) {
        return 0;
    }
    return sum(n - 1) + n;
}

这种常用的递归方式叫“线性递归”。随着n的增大,调用堆栈开辟的空间会随之呈线性增长。

线性递归

2. 求解斐波拉契数列第n项

斐波拉契数列的定义:当 n = 1 时,fibo(1) = 1;当 n = 2 时,fibo(2) = 1;当 n > 2 时, fibo(n) = fibo(n - 1) + fibo(n - 2)

根据以上的定义,我们轻松写出如下用递归方式实现的代码:

function fibo (n) {
    if (n <= 1) {
        return 1;
    }
    return fibo(n - 1) + fibo(n - 2);
}

细心的同学可能已经发现到,fibo 函数每一次调用,都需要递归调用自身两次或者更多次,这种递归方式,我们称为“树形递归”。随着n的增大,调用堆栈开辟的空间随之呈指数增长。

树形递归

无论是线性递归还是树形递归,随着递归深度的增大,系统资源消耗都会加倍剧增。那么我们还有什么优化空间呢?

答案是:尾递归

尾递归

函数调用自身,称为递归。如果尾调用自身,就称为尾递归。

递归非常耗费内存,因为需要同时保存成千上百个调用帧,很容易发生“栈溢出”错误(stack overflow)。但对于尾递归来说,由于只存在一个调用帧,所以永远不会发生“栈溢出”错误。

实例1中求解前n项和,最多需要保存n个调用记录,复杂度 O(n) 。那么改成尾递归,只保留一个调用记录,复杂度 O(1) 。

function sum (n, total) {
   if (n <= 0) return total;
   return sum(n - 1, n + total);;
}

尾递归优化过的 fibo 数列实现如下。

function fibo (n, a = 0, b = 1) {
    if (n <= 1) return b;
    return fibo(n - 1, b, a + b);
}

汉诺塔

提到递归,就不得不提汉诺塔问题。

汉诺塔(又称河内塔)问题是源于印度一个古老传说的益智玩具。大梵天创造世界的时候做了三根金刚石柱子,在一根柱子上从下往上按照大小顺序摞着64片黄金圆盘。大梵天命令婆罗门把圆盘从下面开始按大小顺序重新摆放在另一根柱子上。并且规定,在小圆盘上不能放大圆盘,在三根柱子之间一次只能移动一个圆盘。

例如我们有三个盘子需要从A柱移到C柱:

汉诺塔

我们大概需要的步骤如下:

汉诺塔2

换成抽象步骤表达则是:

  • 第一步:A ---> C
  • 第二步:A ---> B
  • 第三步:C ---> B
  • 第四步:A ---> C
  • 第五步:B ---> A
  • 第六步:B ---> C
  • 第七步:A ---> C

可以看出,移动的步数是:2n - 1,n >= 1。

思路

绝大部分的教科书上,都会用老和尚分工的思路来解释这个汉诺塔递归原理,过程比较臃肿。这里我总结三条原则:

  • 首先,我们要借助 C 柱来将 n - 1 个盘从 A 柱移到 B 柱。
  • 接着,我们将 A 柱上的第 n 号盘移到 C 柱。
  • 最后,我们要借助 A 柱来将 n - 1 个盘从 B 柱移到 C 柱。

代码实现

/**
 * 汉诺塔递归解法
 * @param {number} n 盘数
 * @param {string} A A柱名称
 * @param {string} B B柱名称
 * @param {string} C C柱名称
 */
function hanoi (n, A, B, C) {
    if (n === 1) {
        move(n, A, C);
        return;
    }
    hanoi(n - 1, A, C, B);
    move(n, A, C);
    hanoi(n - 1, B, A, C);
}

/**
 * 移动圆盘
 * @param {number} n 第几号圆盘
 * @param {string} N 起始柱子编号
 * @param {string} M 结束柱子编号
 */
function move (n, N, M) {
    console.log('把第' + n + '号圆盘从 ' + N + ' 柱移到 ' + M + ' 柱');
}

分析以上代码得知,汉诺塔递归解法,也是“树形递归”的一种。

排列组合

排列组合在数学领域是非常出名的,在ACM训练题中也是常客。例如:字母ABC的全排列有:ABC、ACB、BAC、BCA、CBA、CAB。

思路

在有 n 个元素的数组中,按顺序抽取一个元素当数组的第一个元素,剩下的 n - 1 元素递归完成同样操作。

代码实现

/**
 * 排列组合递归实现
 * @param {array}  arr    待排列数组
 * @param {number} start  开始坐标
 * @param {number} end    结束坐标
 */
function permute (arr, start, end) {
    if (start === end) {
        echo(arr);
        return;
    }
    for (var i = start; i <= end; i++) {
        swap(arr, start, i);
        permute(arr, start + 1, end);
        swap(arr, start, i);
    }
}

/**
 * 输出数组排列结果
 * @param {array} arr 
 */
function echo (arr) {
    console.log(arr.join(''));
}

/**
 * 交换数组中指定坐标的两个元素的值
 * @param {*} arr 待交换值的数组
 * @param {*} i   下标i
 * @param {*} j   下标j
 */
function swap (arr, i, j) {
    var temp = arr[i];
    arr[i] = arr[j];
    arr[j] = temp;
}

排列组合的递归实现方式,是属于“树形递归”的一种,千万别给它函数体内只有一个递归调用而被蒙骗,因为它在一个 for 循环里面。

N皇后问题

N皇后问题是指:N*N 的棋盘要摆 N 个皇后,要求任何两个皇后不同一行、不同一列、也不在同一条斜线上。给定一个正整数 n,返回 n 皇后的摆法有多少种。

N皇后问题

思路

如果第 i 行,第 j 列放置了一个皇后,那么哪些位置不能放置皇后呢?

  1. 第 i 行剩下空余的所有位置都不能放置皇后。
  2. 第 j 列剩下空余的所有位置都不能放置皇后。
  3. 第 a 行,第 b 列位置若与(i,j)成对角线,即满足 |a - i| = |b - j|,都不能放置皇后。

这里,我们采用的实现方式是递归回溯。递归回溯,本质上是一种枚举法。这种方法从棋盘的第一行开始尝试摆放第一个皇后,摆放成功后,递归一层,再遵循规则在棋盘第二行来摆放第二个皇后。如果当前位置无法摆放,则向右移动一格再次尝试,如果摆放成功,则继续递归一层,摆放第三个皇后......

我们用一维数组arr来代表以找到符合条件的皇后坐标,row代表行数,arr[row]代表列数。

代码实现

/**
 * 判断坐标(row,col)是否安全
 * @param {array}  arr 
 * @param {number} row 
 * @param {number} col 
 */
function isSafe (arr, row, col) {
    for (var i = 0; i < row; i++) {
        if (col === arr[i] || Math.abs(arr[i] - col) === Math.abs(i - row)) {
            return false;
        }
    }
    return true;
}

/**
 * 寻找N皇后问题的解法数
 * @param {number} row 
 * @param {array}  arr 
 * @param {number} n 
 */
function NQueen (row, arr, n) {
    // 若所有行都被搜索完,则说明本次方案可靠!
    if (row === n) {
        return 1;
    }
    // 返回的结果
    var ans = 0;
    
    // 对于第row行,每一列都可能是皇后的摆放位置
    for (var col = 0; col < n; col++) {
        //如果该列满足条件,递归寻找下一行皇后可以摆放的位置
        if (isSafe(arr, row, col)) {
            arr[row] = col;
            ans += NQueen(row + 1, arr, n);
        }
    }

    return ans;
}

很显然,N皇后问题,也是属于“树形递归的一种”。

参考链接

JavaScript 栈

简介

栈就是和列表类似的一种数据结构,它可用来解决计算机世界里的很多问题。栈是一种高效的数据结构,因为数据只能在栈顶添加或删除,俗称“后进先出”,所以这样的操作很快,而且容易实现。栈的使用遍布程序语言实现的方方面面,从表达式求值到处理函数调用。

定义

栈是一种特殊的列表,栈内的元素只能通过列表的一端访问,这一端称为栈顶。就像日常生活中有很多个盘子堆叠起来,我们只能在这些盘子最上面取盘子和加盘子。

栈被称为一种后入先出(LIFO,last-in-first-out)的数据结构。由于栈具有后入先出的特点,所以任何不在栈顶的元素都无法访问。为了得到栈底的元素,必须先拿掉上面的元素。

对栈的两种主要操作是将一个元素压入栈和将一个元素弹出栈。入栈使用 push() 方法,出栈使用 pop() 方法。另一个常用的操作是预览栈顶的元素。pop() 方法虽然可以访问栈顶的元素,但是调用该方法后,栈顶元素也从栈中被永久性地删除了。peek() 方法则只返回栈顶元素,而不删除它。

栈(Stack)类实现

完整代码地址,这里我们采用的底层数据结构依然是数组。

1. 构造函数

构造函数中,有两个属性,一个是 dataList ,用来存储栈中的元素;另一个是 top,用来记录当前栈顶的指针位置,默认为-1。

function Stack () {
    this.dataList = [];
    this.top = -1;
}

2. push:入栈操作

Stack.prototype.push = function (el) {
    this.dataList[++this.top] = el;
}

3. pop:出栈操作

Stack.prototype.pop = function () {
    return this.dataList[this.top--];
}

4. peek:获取栈顶元素操作

Stack.prototype.peek = function () {
    return this.dataList[this.top];
}

5. clear:清除栈中所有元素

Stack.prototype.clear = function () {
    delete this.dataList;
    this.dataList = [];
    this.top = -1;
}

6. 获取栈中元素个数

Stack.prototype.length = function () {
    return this.top + 1;
}

栈(Stack)类测试

var st = new Stack();
st.push(1);
st.push(2);
console.log(st.peek()); // 2
st.push(3);
console.log(st.length()); // 3
console.log(st.peek()); // 3
console.log(st.pop()); // 3
console.log(st.peek()); // 2
st.clear();
console.log(st.length()); // 0

栈(Stack)类实战

1. 简单的数值之间转换

可以利用栈将一个数字从一种数制转换成另一种数制。假设想将数字 n 转换为以 b 为基数
的数字,实现转换的算法如下。

  • 最高位为 n % b,将此位压入栈。
  • 使用 n / b 代替 n
  • 重复步骤以上两个步骤,直到 n 等于 0,且没有余数。
  • 持续将栈内元素弹出,直到栈为空,依次将这些元素排列,就得到转换后数字的字符串形式。

使用栈来实现这个算法,就是小菜一碟(此算法只使用2-9进制的数),具体实现如下:

function mulBase (num, base) {
    var st = new Stack();
    while (num > 0) {
        st.push(num % base);
        num = Math.floor(num / base);
    }
    var converted = "";
    while (st.length() > 0) {
        converted += st.pop();
    }
    return converted;
}

2. 回文

回文是指这样一种现象:一个单词、短语或数字,从前往后写和从后往前写都是一样的。比如,单词“dad”、“racecar”就是回文;如果忽略空格和标点符号,下面这个句子也是文,“A man, a plan, a canal: Panama”;数字 1001 也是。

使用栈,可以轻松判断一个字符串是否是回文。我们将拿到的字符串的每个字符按从左至右的顺序压入栈。当字符串中的字符都入栈后,栈内就保存了一个反转后的字符串,最后的字符在栈顶,第一个字符在栈底。

字符串完整压入栈内后,通过持续弹出栈中的每个字母就可以得到一个新字符串,该字符串刚好与原来的字符串顺序相反。我们只需要比较这两个字符串即可,如果它们相等,就是一个回文。

具体实现算法如下:

function isPalindrome (word) {
    var st = new Stack();
    word += ''; // 转换为字符串
    for (var i = 0, len = word.length; i < len; i++) {
        st.push(word[i]);
    }
    var reverseWord = "";
    while (st.length() > 0) {
        reverseWord += st.pop();
    }
    return word === reverseWord;
}

JavaScript 列表

简介

在日常生活中,人们经常使用列表:待办事项列表、购物清单、十佳榜单、最后十名榜单等。在JavaScript中的数组,我们常常可以当列表来用,因为它不是像强类型语言中的数组那样严格,它存储的元素可以是任意类型。为了阐述好列表的概念,我们从实现一个列表(List)类来一步步认识列表。

定义

列表是一组有序的数据。每个列表中的数据项称为元素。在 JavaScript 中,列表中的元素可以是任意数据类型。列表中可以保存多少元素并没有事先限定,实际使用时元素的数量受到程序内存的限制。不包含任何元素的列表称为空列表。

我们可以约定以下:

列表属性

  • 列表中包含元素的个数称为列表的 size
  • 列表中当前位置记录为 pos
  • 存储列表元素的数组 dataList

列表方法

  • 使用 append 方法在列表末尾添加 一个元素。
  • 使用 insert 方法在一个给定元素后或列表的起始位置插入一个元素。
  • 使用 remove 方法从列表中删除元素。
  • 使用 clear 方法清空列表中所有的元素。
  • 使用 length 方法返回列表中又多少个元素。
  • 使用 toString 方法显示列表中所有的元素。
  • 使用 getElement 方法显示当前元素。
  • 使用 front 方法将列表当前位置移动到第一个位置。
  • 使用 end 方法将列表当前位置移动到最后一个位置。
  • 使用 prev 方法将列表当前位置前移一位。
  • 使用 next 方法将列表当前位置后移一位。
  • 使用 currPos 方法返回列表当前位置。
  • 使用 moveTo 方法将列表当前位置移到指定的位置。
  • 使用 contains 方法判断接受的参数是否在列表中存在。

列表(List)类实现

完整代码地址

1. 构造函数

根据上面的约定,可以直接实现一个List类。它有三个属性,分别是列表元素个数 size,列表当前位置 pos,存储列表元素的数组 dataList

function List () {
    var args = [].slice.call(arguments);
    this.size = args.length;
    this.pos = -1;
    this.dataList = args;
}

2. append:给列表添加元素

这里我们按照JavaScript习惯性声明类方法,不按照书本上的写法。

List.prototype.append = function (el) {
    this.dataList[this.size++] = el;
}

3. find:找出给定元素的下标

若不存在该元素,则返回-1。这个方法大多时候的作用是辅助其他方法。

List.prototype.find = function (el) {
    for (var i = 0, len = this.dataList.length; i < len; i++) {
        if (this.dataList[i] === el) {
            return i;
        }
    }
    return -1;
}

4. remove:从列表中删除元素

如果元素删除成功,该方法返回true,否则返回false。

List.prototype.remove = function (el) {
    var findIdx = this.find(el);
    if (findIdx > -1) {
        this.dataList.splice(findIdx, 1);
        this.size--;
        return true;
    }
    return false;
}

5. length:返回列表中有多少个元素

直接返回属性 size 的值。

List.prototype.length = function () {
    return this.size;
}

6. toString:以字符串的形式显示列表中的元素

列表元素之间默认以逗号分隔。

List.prototype.toString = function () {
    return this.dataList.join();
}


7. Insert:向列表中插入一个元素

Insert方法接受两个参数,一个是要插入的元素,另外一个是要插入的下标位置。插入成功返回true,否则返回false。

List.prototype.insert = function (el, pos) {
    if (pos > -1 && pos < this.size) {
        this.dataList.splice(pos, 0, el);
        this.size++;
        return true;
    }
    return false;
}

8. clear:清空列表中所有的元素

先将原来的 dataList 从内存中 delete,然后在创建新的 dataList,最后重置 sizepos 属性。

List.prototype.clear = function () {
    delete this.dataList;
    this.dataList = [];
    this.size = 0;
    this.pos = -1;
}

9. contains:判断给定的值是否在列表中

若列表中存在给定的元素值,返回true,否则返回false。

List.prototype.contains = function (el) {
    for (var i = 0, len = this.dataList.length; i < len; i++) {
        if (this.dataList[i] === el) {
            return true;
        }
    }
    return false;
}

10. 遍历列表的相关方法

List.prototype.front = function () {
    this.pos = 0;
}

List.prototype.end = function () {
    this.pos = this.size -1;
}

List.prototype.prev = function () {
    if (this.pos > -1) {
        this.pos--;
    }
}

List.prototype.next = function () {
    if (this.pos < this.size) {
        this.pos++;
    }
}

List.prototype.currPos = function () {
    return this.pos;
}

List.prototype.moveTo= function (pos) {
    this.pos = pos;
}

List.prototype.getElement = function () {
     if (this.pos > -1 && this.pos < this.size) {
        return this.dataList[this.pos];
    } else {
        console.warning('当前列表位置越界,请调整指针');
        return null;
    }

列表(List)类测试

我们先创建一个由字符串组成的列表,然后测试以上定义的方法。

var list = new List('Sunday', 'Monday', 'Tuesday');
// 以字符串形式输出列表元素
console.log(list.toString()); // Sunday,Monday,Tuesday

// 在列表末尾添加一个元素
list.append('Thursday');
// 以字符串形式输出列表元素
console.log(list.toString()); // Sunday,Monday,Tuesday,Thursday

// 删除列表一个元素
list.remove('Sunday');
// 以字符串形式输出列表元素
console.log(list.toString()); // Monday,Tuesday,Thursday

// 向列表插入元素
list.insert('Sunday', 0);
list.insert('Wednesday', 3);
// 以字符串形式输出列表元素
console.log(list.toString()); // Sunday,Monday,Tuesday,Wednesday,Thursday

// 判断列表是否包含指定的元素
console.log(list.contains('Friday')); // false

// 向列表末尾追加元素
list.append('Friday');
list.append('Saturday');

// 判断列表中是否存在指定的元素
console.log(list.contains('Friday')); // true
// 输出列表元素个数
console.log(list.length()); // 7

然后我们尝试利用遍历方法来遍历列表。

for (list.front(); list.currPos() < list.length(); list.next()) {
    console.log(list.getElement());
}

执行结果

Sunday
Monday
Tuesday
Wednesday
Thursday
Friday
Saturday

LeetCode(力扣)答案解析(五)

21. 合并两个有序链表

将两个有序链表合并为一个新的有序链表并返回。新链表是通过拼接给定的两个链表的所有节点组成的。

示例:

输入:1->2->4, 1->3->4
输出:1->1->2->3->4->4

题解:

/**
 * Definition for singly-linked list.
 * function ListNode(val) {
 *     this.val = val;
 *     this.next = null;
 * }
 */
/**
 * @param {ListNode} l1
 * @param {ListNode} l2
 * @return {ListNode}
 */
var mergeTwoLists = function(l1, l2) {
    var ans = new ListNode(null),
        curNode = ans;
    while (l1 !== null && l2 !== null) {
        if (l1.val <= l2.val) {
            curNode.next = l1;
            l1 = l1.next;
        } else {
            curNode.next = l2;
            l2 = l2.next;
        }
        curNode = curNode.next;
    }
    curNode.next = (l1 !== null) ? l1 : l2;
    return ans.next;
};

解析:

这道题比较简单,思路大概用一个中间链表ans来存储结果,然后不断找出两个列表较小值,直到ans的末尾,不断重复这个步骤,直到l1或l2都遍历完,然后将没遍历完的l1或者l2接到ans后面。

26. 删除排序数组中的重复项

给定一个排序数组,你需要在原地删除重复出现的元素,使得每个元素只出现一次,返回移除后数组的新长度。

不要使用额外的数组空间,你必须在原地修改输入数组并在使用 O(1) 额外空间的条件下完成。

示例 1:

给定数组 nums = [1,1,2], 

函数应该返回新的长度 2, 并且原数组 nums 的前两个元素被修改为 1, 2 

你不需要考虑数组中超出新长度后面的元素。

示例2:

给定 nums = [0,0,1,1,1,2,2,3,3,4],

函数应该返回新的长度 5, 并且原数组 nums 的前五个元素被修改为 0, 1, 2, 3, 4

你不需要考虑数组中超出新长度后面的元素。

题解:

/**
 * @param {number[]} nums
 * @return {number}
 */
var removeDuplicates = function(nums) {
    var count = nums.length > 0 ? 1 : 0;
    for (var i = 1, len = nums.length; i < len; i++) {
        if (nums[count - 1] ^ nums[i]) { // 若不相同
            nums[count++] = nums[i];
        }
    }
    return count;
};

解析:

因为数组本来就有序的,首先我们要记录当前遍历位置一共有多少个不同的元素,我们用count表示,然后数组的前count个元素面要始终保持互不相同。这样,我们循环对比的时候,只需和第count - 1元素是否相等,若相等,继续寻找下一个不同的元素;若不相等,则将这个不同的数,替换在count位置。这样,count的值就是始终代表着原始数组中有多少个不同的值。

图解:

default

237. 删除链表中的节点

请编写一个函数,使其可以删除某个链表中给定的(非末尾)节点,你将只被给定要求被删除的节点。

现有一个链表 -- head = [4,5,1,9],它可以表示为:

示例 1:

输入: head = [4,5,1,9], node = 5
输出: [4,1,9]
解释: 给定你链表中值为 5 的第二个节点,那么在调用了你的函数之后,该链表应变为 4 -> 1 -> 9.

示例 2:

输入: head = [4,5,1,9], node = 1
输出: [4,5,9]
解释: 给定你链表中值为 1 的第三个节点,那么在调用了你的函数之后,该链表应变为 4 -> 5 -> 9.

题解:

/**
 * Definition for singly-linked list.
 * function ListNode(val) {
 *     this.val = val;
 *     this.next = null;
 * }
 */
/**
 * @param {ListNode} node
 * @return {void} Do not return anything, modify node in-place instead.
 */
var deleteNode = function(node) {
    node.val = node.next.val;
    node.next = node.next.next;
};

解析:

这道题,很简单。但是很多同学会有一个误区,就是真的会删除当前节点,步骤会变得繁琐。其实换种思路,将当前节点的值替换为下一个节点的值,然后删除下一个节点,问题就变得简单多了。

JavaScript 检索算法

前言

作为最基本的计算机编程任务,数据检索已经被研究了很多年,在这里,我们只讨论如何查找特定的值。

在列表中查找数据有两种方式:顺序查找二分查找。顺序查找适用于元素随机排列的列表;二分查找适用于元素已排序的列表。二分查找效率更高,但是你必须在进行查找之前花费额外的时间将列表中的元素排序。

顺序查找

对于查找数据来说,最简单的方法就是从列表的第一个元素开始对列表元素逐个进行判断,直到找到了想要的结果,或者直到列表结尾也没有找到。这种方法称为 顺序查找,有时也被称为线性查找。它属于暴力查找技巧的一种,在执行查找时可能会访问到数据结构里的所有元素。

普通查找

function seqSearch (arr, data) {
    for (var i = 0, len = arr.length; i < len; i++) {
        if (arr[i] === data) {
            return true;
        }
    }
    return false;
}

查找最小值

function findMin (arr) {
    var min = arr[0];
    for (var i = 1, len = arr.length; i < len; i++) {
        if (arr[i] < min) {
              min = arr[i];
        } 
    }
    return min; 
}

查找最大值

function findMax (arr) {
    var max = arr[0];
    for (var i = 1, len  = arr.length; i < len; i++) {
        if (arr[i] > max) {
              max = arr[i];
        }
    }
    return max;
}

二分查找

如果你要查找的数据是有序的,二分查找算法比顺序查找算法更高效。

function binSearch (arr, data) {
    var len = arr.length
    var high = len - 1;
    var low = 0;
    while (low <= high) {
        var mid = Math.floor((low + high) / 2);
        if (arr[mid] < data) {
             low = mid + 1;
        } else if (arr[mid] > data) {
             high = mid - 1;
        } else {
            return mid;
        }
    }
    return -1;
}

因为这一章相对比较简单,我们过一遍就差不多了。

LeetCode(力扣)答案解析(三)

9. 回文数

判断一个整数是否是回文数。回文数是指正序(从左向右)和倒序(从右向左)读都是一样的整数。

示例1:

输入: 121
输出: true

示例2:

输入: -121
输出: false
解释: 从左向右读,  -121  从右向左读,  121- 。因此它不是一个回文数。

示例3:

输入: 10
输出: false
解释: 从右向左读,  01 。因此它不是一个回文数。

你能不将整数转为字符串来解决这个问题吗?

解法一

/**
 * @param {number} x
 * @return {boolean}
 */
var isPalindrome = function (x) {
    if (x < 0) return false;
    var t = x, y = 0;
    do {
        y = y * 10 + t % 10;
        t = Math.floor(t / 10);
    } while (t != 0);
    return y === x;
};

这种解法的思路很简单,就是存粹反转数字,看最后反转的结果和原数字对比,是否相等。细心的同学可以看出,这个算法是有优化的空间的:我们只需将数字按中间划分,只要左右反转相等就证明是回文数了,时间复杂度将减半。

解法二(优化)

/**
 * @param {number} x
 * @return {boolean}
 */
var isPalindrome = function (x) {
    if (x < 0 || (x && x % 10 === 0)) return false;
    var num = 0;
    while (x > num) {
        num = num * 10 + x % 10;
        x = Math.floor(x / 10);
    }
    return x === num || x === Math.floor(num / 10);
};

这个算法的优化,注意点在于在10的倍数的时候需要特殊处理,直接返回false。

14. 最长公共前缀

编写一个函数来查找字符串数组中的最长公共前缀。

如果不存在公共前缀,返回空字符串 ""。

示例1:

输入: ["flower","flow","flight"]
输出: "fl"

示例2:

输入: ["dog","racecar","car"]
输出: ""
解释: 输入不存在公共前缀。

说明:

所有输入只包含小写字母 a-z

解法一(暴力法)

/**
 * @param {string[]} strs
 * @return {string}
 */
var longestCommonPrefix = function(strs) {
    var ans = '';
    if (strs.length) {
        var str = strs[0], isBreak = false;
        for (var i = 0, len = str.length; i < len; i++) {
           for (var j = 1, len2 = strs.length; j < len2; j++) {
               if (i < strs[j].length && str[i] === strs[j][i]) continue;
               isBreak = true;
               break;
           }
           if (!isBreak) {
               ans += str[i];
               continue;
           }
           break;
        }
    }
    return ans;
};

这里解题思路大致是:以数组strs第一个元素作为字符串模板,然后将它和数组后面字符串进行逐个字符从左到右进行遍历对比,若找到不相同,直接跳出循环;若相同,则记录在ans,直到超出最短的字符串长度或者找到不同的字符。这个算法时间复杂度为O(n*m),n是strs数组的长度,m是最短字符串的长度。

另外,需要注意的两点是:

  • 当传入的strs数组为空时,默认返回"",在这里我们并没有处理,因为ans默认为""
  • 每次字符串间的字符对比,都需要检测遍历的下标是否越界了。若是,直接跳出循环。

53. 最大子序和

给定一个整数数组 nums ,找到一个具有最大和的连续子数组(子数组最少包含一个元素),返回其最大和。
示例:

输入: [-2,1,-3,4,-1,2,1,-5,4],
输出: 6
解释: 连续子数组 [4,-1,2,1] 的和最大,为 6

进阶:

如果你已经实现复杂度为 O(n) 的解法,尝试使用更为精妙的分治法求解。

解法一(暴力法)

/**
 * @param {number[]} nums
 * @return {number}
 */
var maxSubArray = function(nums) {
    var front = 0, rear = nums.length - 1;
    var ans = front <= rear ? nums[0] : 0;
    while (front <= rear) {
        var max = rear;
        while (max >= front) {
            var sum = 0;
            for (var i = max; i >= front; i--) {
                sum += nums[i];
            }
            (sum > ans) && (ans = sum);
            max--;
        }
        front++;
    }
    return ans;
};

看完题目后,二话不说,一上来就是用暴力法,头就是铁!然而,提交答案的时候,报超时...... 暴力法的时间复杂度足足是O(n3),非常恐怖。那么,如果我们换种思路,头不那么铁,动动脑筋,能不能降低时间复杂度呢?

解法二(动态规划)

/**
 * @param {number[]} nums
 * @return {number}
 */
var maxSubArray = function(nums) {
    var ans = 0, len = nums.length;
    if (len > 0) {
         ans = nums[0];
         for (var i = 1; i < len; i++) {
             if (nums[i - 1] > 0) nums[i] += nums[i - 1];
             if (ans < nums[i]) ans = nums[i];
         }
    }
    return ans;
};

这个解法运用了动态规划。动态规划是求解最优解的能手。简单来说,我们这里将数组nums中第i个数,记录着这i个数之前的最大子序列和,只要前i-1个数的最大子序列和出现负数,则不相加,因为任何数加上负数,都会越加越小。而这个解法的时间复杂度只为O(n),性能明显提升了很多。具体请看以下图例:

default

另外,题目竟然提示了还有更精妙的分治方法。不过我本人认为动态规划的解法才是最精妙的!题目既然明示了分治方法,那么这里我也相应地给出这种解题思路:

解法三(分治算法)

/**
 * @param {number[]} nums
 * @return {number}
 */
var maxSubArray = function(nums) {
    return maxSubArrayHelper(nums, 0, nums.length - 1);
};
function maxSubArrayHelper(nums, left, right) {
    if (left >= right) {
        return nums[left];
    }
    var center = parseInt((left + right) / 2);
    var maxLeftSubArray = maxSubArrayHelper(nums, left, center);
    var maxRightSubArray = maxSubArrayHelper(nums, center + 1, right);
    // 求左边界最大子序列和
    var leftSum = 0, maxLeftSum = nums[center];
    for (var i = center; i >= left; i--) {
        leftSum += nums[i];
        leftSum > maxLeftSum && (maxLeftSum = leftSum);
    }
    // 求右边界最大子序列和
    let rightSum = 0, maxRightSum = nums[center + 1];
    for (var i = center + 1; i <= right; i++) {
        rightSum += nums[i];
        rightSum > maxRightSum && (maxRightSum = rightSum);
    }
    // 返回结果
    return Math.max(maxLeftSubArray, maxRightSubArray, maxLeftSum + maxRightSum);
}

分治算法的**不难理解,就是分而治之,将较复杂的问题,一分为二求解,以此类推。那么这里需要注意的是:

  • JavaScript中整数间非整除的运算,最后得到的结果将会是小数,float类型,有一个隐式转换的过程。我们需要借助parseInt方法来将结果强制转化为整数。
  • 分治能分别求出左右子序列的最优解,但忽略了中间组合的最优解,这里我们需要利用左右边界较中间位置,额外计算中间部分的最优解。

LeetCode(力扣)答案解析(一)

1. 两数之和

给定一个整数数组 nums 和一个目标值 target,请你在该数组中找出和为目标值的那 两个 整数,并返回他们的数组下标。

你可以假设每种输入只会对应一个答案。但是,你不能重复利用这个数组中同样的元素。

示例

给定 nums = [2, 7, 11, 15], target = 9

因为 nums[0] + nums[1] = 2 + 7 = 9
所以返回 [0, 1]

解法一(暴力法)

/**
 * @param {number[]} nums
 * @param {number} target
 * @return {number[]}
 */
var twoSum = function (nums, target) {
    var len = nums.length;
    for (var i = 0; i < len; i++) {
        for (var j = i + 1; j < len; j++) {
            if (nums[i] + nums[j] === target) {
                return [i, j];
            }
        }
    }
    return [];
};

暴力法是纯粹枚举所有组合的和,是否与target的值相等。

解法二(两次哈希遍历)

/**
 * @param {number[]} nums
 * @param {number} target
 * @return {number[]}
 */
var twoSum = function (nums, target) {
    var len = nums.length;
    var hashMap = {};
    for (var i = 0; i < len; i++) {
        // 记录每个数值对应数组的下标
        hashMap[nums[i]] = i;
    }
    for (var i = 0; i < len; i++) {
        // 计算差值
        var res = target - nums[i];
        // 若差值在hashMap存在,且下标不相等(题目要求不相同的数)
        if (hashMap[res] && hashMap[res] !== i) {
            return [i, hashMap[res]];
        }
    }
    return [];
};

两次哈希遍历,利用了对象中的key唯一性,而其值存放该key在nums数组下的下标,方便后面对比和组织返回结果。虽然两次哈希遍历需要额外的存储空间,但比暴力法快了不少,这就是时间和空间上作出的权衡,然而,这种方法并不是最快的方法。

解法三(一次哈希遍历)

/**
 * @param {number[]} nums
 * @param {number} target
 * @return {number[]}
 */
var twoSum = function (nums, target) {
    var len = nums.length;
    var hashMap = {};
    for (var i = 0; i < len; i++) {
        var res = target - nums[i];
        if (hashMap[res] || hashMap[res] !== 0) {  // 兼容下标为0的情况
            return [hashMap[res], i];
        }
        hashMap[nums[i]] = i;
    }
    return [];
};

一次哈希遍历在时间复杂度和两次哈希遍历是一样的,但是一次哈希遍历,少了一次遍历nums数组。这里的思路是:一边构造hashMap,一边用target减去当前遍历到下标为i的nums数组中的数,看差值是否在hashMap中,若存在,则返回结果,若不存在,则继续扫描后面的数。

136. 只出现一次的数字

给定一个非空整数数组,除了某个元素只出现一次以外,其余每个元素均出现两次。找出那个只出现了一次的元素。

说明:

你的算法应该具有线性时间复杂度。 你可以不使用额外空间来实现吗?

示例1:

输入: [2,2,1]
输出: 1

示例2:

输入: [4,1,2,1,2]
输出: 4

解法一(暴力法 + 双重循环)

/**
 * @param {number[]} nums
 * @return {number}
 */
var singleNumber = function(nums) {
    var len = nums.length;
    for (var i = 0; i < len; i++) {
        var isSame = false;
        for (var j = 0; j < len; j++) {
            // 两数下标不同,且值相同
            if (i !== j && nums[i] === nums[j]) {
                // 标志已经找到了相同的数
                isSame = true;
                // 跳出当前循环
                break;
            }
        }
        // 若不存在相同的数
        if (!isSame) {
            return nums[i];
        }
    }
    return null;
};

这个解法真的暴力,既耗时,也违反题目要求(线性时间),但是这个是最直接的做法。

解析二(线性时间 + 借助对象排重)

/**
 * @param {number[]} nums
 * @return {number}
 */
var singleNumber = function(nums) {
    var len = nums.length;
    var obj = {};
    for (var i = 0; i < len; i++) {
        var num = nums[i];
        // 判断该数是否存在键值对,若有其值加1,若没有,初始化其值为1
        obj[num] ? obj[num]++ : obj[num] = 1;
    }
    for (var item in obj) {
        // 若该数只出现一次
        if (obj[item] === 1) {
            return item;
        }
    }
    return null;
};

这个方法,运用了对象的key唯一的特性来区分统计数组中的值出现次数,最后遍历一次对象,匹配出其值为1的key值,这就是答案了。虽然这个方法有着不错的性能,但是还是违反题目中不能运用额外的空间的要求。

解法三(遍历数组逐一异或(^))

/**
 * @param {number[]} nums
 * @return {number}
 */
var singleNumber = function(nums) {
    var len = nums.length;
    var result = 0;
    for (var i = 0; i < len; i++) {
        result = result ^ nums[i];
    }
    return result;
};

What?异或是什么?我是谁?我在哪里?别急,异或其实很好理解。异或,是JavaScript和很多其他高级语言的位运算符,我们把这个运算过程叫做位运算。异或是指两数对应位数的值不同则返回1,若相同则返回0。举个例子1 ^ 1 = 0, 而 1 ^ 0 = 1。利用这个特性,我抓住题意中:数组中只有一个数字出现一次,其它数字均出现两次。那么两个相同的数异或永远等于0,而0与任何数,都等于其数本身。利用这个特性,我们可以逐一遍历数组中的数,然后逐一异或,得到最后的结果,就是只出现一次的数字了。异或详细讲解

231. 2的幂

给定一个整数,编写一个函数来判断它是否是 2 的幂次方。

示例1:

输入:1
输出:true
解释:2的0次方 = 1

示例2:

输入: 16
输出: true
解释: 2的4次方 = 16

示例3:

输入: 218
输出: false

解法一(对数求值 + 求整对比)

/**
 * @param {number} n
 * @return {boolean}
 */
var isPowerOfTwo = function(n) {
    var temp = Math.log2(n);
    return temp === Math.floor(temp);
};

这个方法是纯粹式数学思维解题。

解法二(求与对比)

/**
 * @param {number} n
 * @return {boolean}
 */
var isPowerOfTwo = function(n) {
    return n <= 0 ? false : (n & (n-1)) === 0;
};

这个方法非常巧妙。经过我们分析,2的n次方,例如2、4、8、16 ... 对应的二进制为10100100010000 ... 可以看到,2的n次方对应其二进制数,都是开头1然后后面跟着全是0,那么当这个数减1之后,开头就变成了0,后面跟着全是1。例如2 - 1 = 1 = 01(二进制) 4 - 1 = 3 = 011(二进制)8 - 1 = 7 = 0111(二进制) ... 可以发现,2的n次幂与自身减1相与,那么,结果必定是0,数字1同样适用这个规律(1 & 0 = 0)。

ES6 初识

前言

相信,对于从事Web前端的同学,都不好意思说,自己不会ES6。我最初接触的ES6的时候,大概在2016年中旬,那正是React刚刚在国内开始火,Vue2.0发布不久,AngularJS1.4.x 一步步衰落,而Angular2.0又准备发布的时候。

当时,带我入行前端的一位师兄,我尊称他为:豪哥。他建议我学习React来实现当时一个类似页面设计器的需求,学习途中,认识到了刚刚兴起的ES6,不然我还得借助React.createClass这个API来创建React组件。

直至今天,我还是觉得自己学到的ES6知识还是比较粗糙的,所以决定重拾ES6,并结合自己的一些理解和实践分享出来。在这里,特别感谢阮一峰老师、张鑫旭老师,感觉对于像我这种自学前端的同学来说,大概很难绕开这两位大神。我们要感谢开源社区,感谢各位开源贡献者。

ES6是什么?

ES6,全称ECMAScript6.0,是当时(2015、2016年)JavaScript下一代的标准。如今,我们应该称它为“当代JavaScript标准”。除了ES6,还有其他诸如ES3、ES5、ES7、ES8等版本。下面我归纳了一些知识要点:

  • JavaScript诞生在1995年(那年我也诞生了),而ES6在2015年6月份正式发布。
  • ES6目标是让JavaScript能编写更复杂的大型应用,成为企业级的开发语言(不雅于Java)。
  • ECMAScript是JavaScript的标准,JavaScript是ECMAScript的一种实现。
  • JavaScript和Java一点关系都没有,除了大家都是类C语言之外,当时发布这个名字,纯属为了“蹭热点”。
  • ES5相对ES3改动比较少,而ES6相对ES5改动比较大,因为ES6从制定到发布,足足用了15年。
  • ES4没被纳入标准,大概是因为ES4太激进了,标准委员会(TC39)没予以通过,而且之后还产生许多分歧。

想看更详细请戳这里

为什么要学ES6?

这个问题提得非常好,用ES5甚至ES3不好吗?兼容性又好,非得怕前端没什么东西好学,搞多一个ES6(后面还有ES7、ES8),还要借助babel等编译器来编译成ES5,还绕了一个圈。什么?听说微软还搞了一个叫TypeScript的东东,号称是JavaScript的超集。不知道有没同学像两年前的我一样困惑:“一入前端深似海,从此妹子为路人!”

正所谓,天生我才必有用,存在必有其意义所在。我认为有以下几个理由:

  • 任何一个新的东西,特别是技术,它的迭代更新,必定会带来开发效率、性能、易用性、健壮性等功能的提高,而掌握这些新技术,叫”与时俱进“!
  • 人往高处走,水往低处流。而技术栈也应该不断紧跟时代步伐来及时更新。
  • 我们既然暂时无法去指定标准,也只能紧跟标准。
  • 况且、ES6在前端已经普及化了,而且各大框架开始进一步向TypeScript迁移,千万别让自己落伍!

1. 开发效率的提高

// 需求:声明一个常量为1的变量
// ES5
Object.defineProperty(window, 'order', { writable: false, value: 1 });
// 或者
Object.defineProperty(window, 'order', {
  set (x) {
    throw new Error('不能为常量order重新赋值');
  },
  get () {
    return 1;
  }
});
// ES6
const order = 1;

你看,省了多少代码,如果这个例子在实际业务需求中出现较少,那么请看下一个示例:

// 需求:  连接字符和变量
// ES5
var love = '爱';
var str = '对你' + love + love + love + '不完';
console.log(str); // 对你爱爱爱不完
// ES6
let love = '爱'
let str = `对你${love.repeat(3)}不完`
console.log(str); // 对你爱爱爱不完

你别看上面例子少不了多少代码,后面哪天业务改了,让你写几百上千的’爱‘字符串拼接,那估计累的够呛,就算换成循环,也不如函数式编程优雅。

2. 性能

这里我不打算上代码示例,我想用将要发布的Vue3.0来说明这一切。Vue2.x的数据双向绑定是通过ES5中的Object.defineProperty这个API实现的, 而Vue3.0将换成ES6中的对象代理Proxy来替代ES5的方案,听说性能会快1/3,具体数值不太记得了,如果有错,请指正。

3. 易用性

上面两个例子也足以证明ES6的易用性,不过,这里我另外再给出几个在日常业务中常用到的示例:

// 需求:类的继承
// ES6
class Demo extends BaseClass { // 不支持多继承
  constructor () {
    super();
    // pass
  }
  ......
}

ES5实现类的继承我就不展开了,有兴趣的同学,请戳这里,或者自行查看红宝书,没有的同学,请戳这里

对象析构在日常业务中,常见得已经不能再常见了。

// 需求:获取对象属性
var fullName = {
    firstName: 'Liang',
    lastName: 'Checkson'
};
// ES5
var firstName = fullName.firstName,
      lastName = fullName.lastName;
// ES6
let { firstName, lastName } = fullName; 

箭头函数在项目中快用烂了

// ES5
function Foo () {
  this.name = 'Checkson';
  this.sayName = function () {
    var _this = this;
    setTimeout(function () {
      console.log(_this.name);
    }, 1000)
  }
}
var foo = new Foo();
foo.sayName(); // 1秒钟后输出'Checkson'
// ES6
function Foo () {
  this.name = 'Checkson';
  this.sayName = () => {
    setTimeout(() => {
      console.log(this.name);
    }, 1000)
  }
}
let foo = new Foo();
foo.sayName(); // 1秒钟后输出'Checkson'

函数的默认参数

// ES5
function bar (name) {
   var _name = name || 'Checkson';
   console.log(_name);
}
// ES6
function bar (name = 'Checkson') {
  console.log(name)
}

4. 健壮性

ES6弥补了ES3、ES5中的很多不足,让JavaScript这门语言更加强大,大致如下:

  • ES5中只有全局作用域和函数作用域,ES6引入的块级作用域,块级作用域的引入,解决了很多日常业务痛点和降低维护项目的难度,后面章节介绍。
  • ES6扩展了之前ES5的5种基本数据类型:null, undefined, number, string, boolean,加入了一个新的数据类型:Symbol,用来解决对象中只能存在字符串形式的key,且不能相同的痛点。
  • ES6均对变量、字符串、正则、数值、函数、数组、对象处理方法进行扩展和优化。
  • ES6新增SetMap引用类型,来弥补数组Array和对象Object引用类型的不足。
  • ES6新增对象代理ProxyReflect来处理作用于对象时的副作用。
  • ES6新增Promise, Generator, Async(ES7)来应对回调函数在实际业务的痛点,让异步编程更简单。
  • ES6新增Class关键字,让类名正言顺地走进JavaScript。
  • ES6重新定义了模块系统,引入了import, export, export default等关键字。
    .....

总的来说,ES6是当代的JavaScript标准,学习它是天经地义的,学好它是前端本分的事情,学精它是我们每个Web前端同学都需要努力的。我也希望通过自己的绵薄之力,来为开源社区贡献自己的一份力量。

JavaScript 队列

简介

队列也是一种列表,不同的是队列只能在队尾插入元素,在队首删除元素。队列用于存储按顺序排列的数据,先进先出,这点和栈不一样,在栈中,最后入栈的元素反而被优先处理。可以将队列想象成在银行前排队的人群,排在最前面的人第一个办理业务,新来的人只能在后面排队,直到轮到他们为止。

定义

队列是一种先进先出(First-In-First-Out,FIFO)的数据结构(列表的一种表现形式)。队列被用在很多地方,比如提交操作系统执行的一系列进程、打印任务池等,一些仿真系统用队列来模拟银行或杂货店里排队的顾客。

队列(Queue)类实现

完整代码地址,这里我们采用的底层数据结构仍是数组。涉及到的操作大概有:

  • enqueue 方法在队列队尾插入新元素,该操作也称为入队。
  • dequeue 方法在队列对头删除元素,该操作也称为出队。
  • peek 方法读取队头的元素。该操作返回队头元素,但不把它从队列中删除。
  • length 方法来记录队列中存储了多少元素。
  • clear 方法清空队列种的所有元素。
  • toString 方法将队列种的元素输出为字符串形式

1. 构造函数

构造我们可以接收一些可以初始化队列的参数。

function Queue () {
    var args = [].slice.call(arguments);
    this.dataList = args;
}

2. enqueue:入队

JavaScript相对其他编程语言,有一个天然的优势,就是它的数组提供 push 方法,能模仿队列的入队操作。

Queue.prototype.enqueue = function (el) {
    this.dataList.push(el);
}

3. dequeue:出队

JavaScript数组操作除了提供 push 操作,还提供了 shift 方法,来模仿队列的出队操作。

Queue.prototype.dequeue = function () {
    return this.dataList.shift();
}

4. peek:获取队头元素

Queue.prototype.peek = function () {
    return this.dataList[0];
}

5. length:获取队列的元素个数

Queue.prototype.length = function () {
    return this.dataList.length;
}

6. clear:清楚队列种所有元素

Queue.prototype.clear = function () {
    delete this.dataList;
    this.dataList = [];
}

7. toString:将队列转换为字符串

Queue.prototype.toString = function () {
    return this.dataList.join();
}

队列(Queue)类测试

var queue = new Queue('JavaScript', 'HTML', 'CSS');
console.log(queue.toString()); // JavaScript,HTML,CSS
console.log(queue.length());   // 3
queue.enqueue('React');
console.log(queue.toString()); // JavaScript,HTML,CSS,React
console.log(queue.dequeue());  // JavaScript
console.log(queue.peek());        // HTML
queue.clear();
console.log(queue.toString()); // ''

队列(Queue)实战

1. 使用队列对数据进行排序(基数排序)

队列不仅用于执行现实生活中与排队有关的操作,还可以用于对数据进行排序。计算机刚刚出现时,程序是通过穿孔卡输入主机的,每张卡包含一条程序语句。这些穿孔卡装在一个盒子里,经一个机械装置进行排序。我们可以使用一组队列来模拟这一过程。这种排序技术叫做基数排序,参见 Data Structures with C++(Prentice Hall)一书。它不是最快的排序算法,但是它展示了一些有趣的队列使用方法。

对于有限的整型数字,基数排序将数据集扫描 n(n取决于最大位数的数)次。第一次按个位上的数字进行排序,第二次按十位上的数字进行排序,直到队列对比完队列中最高位数的数。每个数字根据对应位上的数值被分在不同的盒子里。假设有如下数字:

91, 46, 85, 15, 92, 35, 31, 22

经过基数排序第一次扫描之后,数字被分配到如下盒子中:

Bin 0:
Bin 1: 91, 31
Bin 2: 92, 22
Bin 3:
Bin 4:
Bin 5: 85, 15, 35
Bin 6: 46
Bin 7:
Bin 8:
Bin 9:

根据盒子的顺序,对数字进行第一次排序的结果如下:

91, 31, 92, 22, 85, 15, 35, 46

然后根据十位上的数值再将上次排序的结果分配到不同的盒子中:

Bin 0:
Bin 1: 15
Bin 2: 22
Bin 3: 31, 35
Bin 4: 46
Bin 5:
Bin 6:
Bin 7:
Bin 8: 85
Bin 9: 91, 92

最后,将盒子中的数字取出,组成一个新的列表,该列表即为排好序的数字:

15, 22, 31, 35, 46, 85, 91, 92

使用队列代表盒子,可以实现这个算法。我们需要九个队列,每个对应一个数字。将所有队列保存在一个数组中,使用取余和除法操作决定个位和十位,或者更高位。算法的剩余部分将数字加入相应的队列,根据个位数值对其重新排序,然后再根据十位上的数值进行排序,直到最高位数值进行排序,结果即为排好序的数字。

示例代码:

function radixSort (nums) {
    // 构建队列数组
    var queues = [];
    for (var i = 0; i < 10; i++) {
        queues[i] = new Queue();
    }
    // 基排序
    var isLoop = true, radix = 10;
    do {
        isLoop = false;
        for (var i = 0, len = nums.length; i < len; i++) {
            var divide = Math.floor(radix / 10),
                temp = Math.floor(nums[i] / divide),
                index = temp % 10;
            if (temp) isLoop = true;
            queues[index].enqueue(nums[i]);
        }
        var j = 0;
        for (var i = 0; i < 10; i++) {
            while (queues[i].length()) {
                nums[j++] = queues[i].dequeue();
            }
        }
        radix *= 10;
    } while (isLoop);
    // 返回基排序结果
    return nums;
}

测试代码:

var nums = [];

for (var i = 0; i < 10; i++) {
    nums[i] = Math.floor(Math.random() * 10000);
}

console.log('排序前:')
console.log(nums.toString());

radixSort(nums);

console.log('排序后:')
console.log(nums.toString());

运行几次测试代码,结果为:

排序前:
5806,7829,7540,3360,9198,7427,4536,5336,1391,4148
排序后:
1391,3360,4148,4536,5336,5806,7427,7540,7829,9198

排序前:
9895,3646,9204,7545,1192,8832,9639,3162,2642,3995
排序后:
1192,2642,3162,3646,3995,7545,8832,9204,9639,9895

可见,基排序的时间复杂度大概为:O(n*m)。其中n是数组长度,m是最大的数的位数。(后面排序算法章节详解)。

2. 优先队列

优先队列和普通队列最大的不同是在于,在出队(dequeue)的时候,优先队列不会遵循先进先出的原则,它会根据队列中元素的权值来判断到底先删除哪个元素。

3. 循环队列

定义,循环队列在JavaScript中应用得很少,是因为JavaScript中的数组是动态的,可以动态增删数组元素,来避免队列溢出的现象。

LeetCode(力扣)答案解析(四)

70. 爬楼梯

假设你正在爬楼梯。需要 n 阶你才能到达楼顶。

每次你可以爬 1 或 2 个台阶。你有多少种不同的方法可以爬到楼顶呢?

注意:给定 n 是一个正整数。

示例1:

输入: 2
输出: 2
解释: 有两种方法可以爬到楼顶。
1.  1  + 1 
2.  2 

示例2:

输入: 3
输出: 3
解释: 有三种方法可以爬到楼顶。
1.  1  + 1  + 1 
2.  1  + 2 
3.  2  + 1 

思路:
这道题目跟斐波那契数列很像。假设梯子有n层,那么如何爬到第n层呢,因为每次只能爬1或2步,那么爬到第n层的方法要么是从第n-1层一步上来的,要不就是从n-2层2步上来的,所以我们得出的递推公式是:dp[n] = dp[n-1] + dp[n-2]

解法一(递归)

/**
 * @param {number} n
 * @return {number}
 */
var climbStairs = function(n) {
    if (n <= 1) return 1;
    return climbStairs(n - 1) + climbStairs(n - 2);
};

这种解法虽然优雅,但提交到leetcode发现超时了,不过是意料之中,我们稍微改进一下。

解法二(数组递推 + 通项公式)

/**
 * @param {number} n
 * @return {number}
 */
var climbStairs = function(n) {
    if (n <= 1) return 1;
    var dp = [1, 2];
    for (var i = 2; i < n; i++) {
        dp[i] = dp[i - 1] + dp[i - 2];
    }
    return dp[n - 1];
};

perfect,AC了!但是还有没有优化的空间呢?可以发现,我们借助了数组来递推公式,那么我们能不能不借助数组,从而节省空间呢?

解法三(动态规划 + 空间复杂度O(1))

/**
 * @param {number} n
 * @return {number}
 */
var climbStairs = function(n) {
    var a = 1, b = 2;
    while (--n > 0) {
       b += a;
       a = b - a;
    }
    return a;
};

我们先用b存储a+b的结果,得到了下一步递推结果。然后用新的b值减去a就得到了旧的b值,然后赋值给a,这样就可以模拟上面递推的过程了。

557. 反转字符串中的单词 III

给定一个字符串,你需要反转字符串中每个单词的字符顺序,同时仍保留空格和单词的初始顺序。

示例1:

输入: "Let's take LeetCode contest"
输出: "s'teL ekat edoCteeL tsetnoc" 

注意: 在字符串中,每个单词由单个空格分隔,并且字符串中不会有任何额外的空格。

思路:
这道题目用JavaScript做非常easy,或者,不用C做算法都算是作弊吧,我们先看函数式编程怎么写:

解法一(函数式编程)

/**
 * @param {string} s
 * @return {string}
 */
var reverseWords = function(s) {
    return s.split(' ').map(function (item) { return item.split('').reverse().join(''); }).join(' ');
};

解法二(自己实现)

/**
 * @param {string} s
 * @return {string}
 */
var reverseWords = function(s) {
   var ans = '', word = '';
   for (var i = s.length - 1; i >= 0; i--) {
       if (s[i] === ' ') {
           ans = s[i] + word + ans;
           word = '';
           continue;
       }
       word += s[i];
       if (i === 0) {
           ans = word + ans;
       }
   }
   return ans;
};

这个解法的思路很粗暴,倒序遍历字符串,这样,每个单词倒序就可以被记录下来,遇到空格就添加到ans字符串中。若遍历到字符串第一个字符了,就直接把剩下的单词拼接上去。

292. Nim游戏

你和你的朋友,两个人一起玩 Nim游戏:桌子上有一堆石头,每次你们轮流拿掉 1 - 3 块石头。 拿掉最后一块石头的人就是获胜者。你作为先手。 你们是聪明人,每一步都是最优解。 编写一个函数,来判断你是否可以在给定石头数量的情况下赢得游戏。

示例:

输入: 4
输出: false 
解释: 如果堆中有 4 块石头,那么你永远不会赢得比赛;
     因为无论你拿走 1 块、2  还是 3 块石头,最后一块石头总是会被你的朋友拿走。

思路

这道题目非常简单,只有石头的个数为4的倍数,那么我们自己必输,其他情况都可以赢。

题解:

/**
 * @param {number} n
 * @return {boolean}
 */
var canWinNim = function(n) {
    if (n > 0) {
        return n % 4 !== 0;
    } else {
        return false;
    }
};

String.prototype.replace详解

前言

曾经的我天真地认为,String.prototype.replace其实没什么好学的,就是一个简单的、通用的字符串替换方法。可是,直到后来我遇到了一个业务问题,调试了足足将近一个小时左右,才把bug锁定在自己当初坚信基本不会用错的 replace 方法上。今天,我不得不花业余的时间,好好聊聊 replace 方法以及常见的坑。

定义

replace() 方法用于在字符串中用一些字符替换另一些字符,或替换一个与正则表达式匹配的子串。

语法

stringObject.replace(regexp/substr,replacement)
参数 描述
regexp/substr 必需。规定子字符串或要替换的模式 RegExp 对象。
replacement 必需。一个字符串值。规定了替换文本或生成替换文本的函数。

返回值

一个新的字符串,是用 replacement 替换了 regexp/substr 的第一次匹配或所有匹配之后得到的。

说明

  • String.prototype.replace 该方法并不会改变字符串本身,只会返回替换后一个全新的字符串。
  • 字符串 stringObject 的 replace() 方法执行的是查找并替换的操作。它将在 stringObject 中查找与 regexp 相匹配的子字符串,然后用 replacement 来替换这些子串。如果 regexp 具有全局标志 g,那么 replace() 方法将替换所有匹配的子串。否则,它只替换第一个匹配子串。
  • replacement 可以是字符串,也可以是函数。如果它是字符串,那么每个匹配都将由字符串替换。但是 replacement 中的 $ 字符具有特定的含义(这就是我之前遇到的坑)。如下表所示,它说明从模式匹配得到的字符串将用于替换。
字符 替换文本
$1、$2、...、$9 与 regexp 中的第 1 到第 99 个子表达式相匹配的文本。
$& 与 regexp 相匹配的子串。
$` 位于匹配子串左侧的文本。
$' 位于匹配子串右侧的文本。
$$ 直接量符号,$转义。
  • 若replacement是一个函数,则用法如下:
stringObject.replace(regexp/substr, function (match, p1, p2, ..., offset, string) {
    // 逻辑代码
});
  • replacement函数参数说明如下表:
变量名 代表的值
match 匹配的子串。(对应于上述的$&。)
p1,p2, ... 假如replace()方法的第一个参数是一个RegExp 对象,则代表第n个括号匹配的字符串。(对应于上述的$1,$2等。)例如,如果是用 /(\a+)(\b+)/ 这个来匹配,p1 就是匹配的 \a+,p2 就是匹配的 \b+。
offset 匹配到的子字符串在原字符串中的偏移量。(比如,如果原字符串是 'abcd',匹配到的子字符串是 'bc',那么这个参数将会是 1)
string 被匹配的原字符串。

注意: ECMAScript v3 规定,replace() 方法的参数 replacement 可以是函数而不是字符串。在这种情况下,每个匹配都调用该函数,它返回的字符串将作为替换文本使用。该函数的第一个参数是匹配模式的字符串。接下来的参数是与模式中的子表达式匹配的字符串,可以有 0 个或多个这样的参数。接下来的参数是一个整数,声明了匹配在 stringObject 中出现的位置。最后一个参数是 stringObject 本身。

实例

实例1(字符串模式)

我们要将 str 字符串中的 “apple” 替换成 “checkson”

var str = "Welcome to visit apple website!";
console.log(str.replace("apple", "checkson")); // Welcome to visit checkson website!
console.log(str);  // Welcome to visit apple website!

可见,String.prototype.replace 方法并不改变字符串本身,具有无害性。

实例2 (正则匹配模式)

var str = "Welcome to visit apple website!";
console.log(str.replace(/apple/, "checkson")); // Welcome to visit checkson website!
console.log(str);  // Welcome to visit apple website!

实例3 (正则匹配全局替换)

var str = "Welcome to visit apple website! Do you like apple?";
console.log(str.replace("apple", "checkson")); // Welcome to visit checkson website! Do you like apple?
console.log(str.replace(/apple/g, "checkson"));  // Welcome to visit checkson website! Do you like checkson?

String 原型链上没有 replaceAll 方法,需要我们自己来封装一个类似的方法

String.prototype.replaceAll = function (findText, replacement) {
    var regExp = new RegExp(findText, "g");
    return this.replace(regExp, replacement);
}

实例4(对大小写不敏感的模式)

var str = "Welcome to visit Apple website!";
console.log(str.replace(/apple/i, "checkson")); // Welcome to visit checkson website!
console.log(str);  // Welcome to visit Apple website!

实例5(第二个参数 replacement 中包含$符号用法)

// $1、$2 、...
"good & handsome".replace(/(\w+)\s*&\s*(\w+)/g, "$2 & $1"); //handsome & good

// $&
"bye".replace(/\w+/g, "$& $&"); //bye bye

// $`
"javascript".replace(/script/, "$& is not $`"); //javascript is not java

// $'
"javascript".replace(/java/, "$&$' is "); // javascript is script

PS:我必须承认,以前我并不知道这些 “*用法” 的(>.<!)。

那么,问题来了,我们替换的字符串中本来就包含 $$, $1, $', $&等字符,那不是炸了?别急,因为 replacement 可以是一个函数,我们可以这样解决:

"xxx replace is ok!".replace(/xxx/, "$1"); // xxx replace is ok!

"xxx replace is ok!".replace(/xxx/, () => "$1"); // $1 replace is ok!

实例6(replacement函数用法)

var _str = 'Just 11 do 22  it';
var str = _str.replace(/(\d)(\d)/g, function (arg1, arg2, arg3, arg4, arg5) {
  console.log(arg1);
  console.log(arg2);
  console.log(arg3);
  console.log(arg4);
  console.log(arg5);
});

输出结果

11
1
1
5
Just 11 do 22  it
22
2
2
11
Just 11 do 22  it

拓展应用

下面介绍一个日常开发中非常实用的 replace 方法应用,来自bootstrap-table中的一个实用方法。

function sprintf (_str) {
    var flag = true,
        args = [].slice.call(arguments, 1),
        i = 0;

    var str = _str.replace(/%s/g, function() {
        var arg = args[i++]
        if (typeof arg === 'undefined') {
            flag = false;
            return '';
        }
        return arg;
    });

    return flag ? str : '';
}

测试

sprintf("%s + %s = %s", 1, 1, 2); // 1 + 1 = 2

这种写法就类似C语言中的 printf 语句,非常优雅。

参考链接

LeetCode(力扣)答案解析(二)

20. 有效的括号

给定一个只包括 '(',')','{','}','[',']' 的字符串,判断字符串是否有效。

有效字符串需满足:

  • 左括号必须用相同类型的右括号闭合。
  • 左括号必须以正确的顺序闭合。

注意空字符串可被认为是有效字符串。

示例 1:

输入: "()"
输出: true

示例 2:

输入: "()[]{}"
输出: true

示例 3:

输入: "(]"
输出: false

示例 4:

输入: "([)]"
输出: false

示例 5:

输入: "{[]}"
输出: true

解法一 (栈 + 手动匹配)

/**
 * @param {string} s
 * @return {boolean}
 */
var isValid = function(s) {
    var len = s.length;
    var st = [], top = -1;
    for (var i = 0; i < len; i++) {
        switch (s[i]) {
            case '(':
            case '[':
            case '{':
                st[++top] = s[i];
                break;
            case ')':
                if (st[top] === '(') {
                    top--;
                } else {
                    st[++top] = s[i];
                }
                break;
            case ']':
                if (st[top] === '[') {
                    top--;
                } else {
                    st[++top] = s[i];
                }
                break;
            case '}':
                if (st[top] === '{') {
                    top--;
                } else {
                    st[++top] = s[i];
                }
                break;
        }
    }
    return top === -1;    
};

这个方法利用栈的特性,如果我们遇到右括号: ),], },我们都可以去匹配栈顶是否存在相应的:(,[, {与之匹配,若不存在,则匹配失败,若存在,则弹出栈顶元素,继续下轮的匹配,直到最后栈中无元素存在,就视为"有效括号"。当然,上述代码中,判断到括号不匹配的时候,可以直接返回false,我这里继续入栈,是因为想保持程序统一入口和出口的原则。

解法二 (栈 + 优化)

/**
 * @param {string} s
 * @return {boolean}
 */
var isValid = function(s) {
    // 记录匹配关系
    var pattern = {
        ')': '(',
        ']': '[',
        '}': '{'
    };
    // 栈
    var st = [], top = -1;
    // 遍历字符串
    for (var i = 0, len = s.length; i < len; i++) {
        // 若是右括号 && 栈中有元素
        if (pattern[s[i]] && top !== -1) {
            // 若栈顶元素与之匹配
            if (st[top] === pattern[s[i]]) {
                // 出栈
                top--;
            } else {
                break;
            }
        } else {
            // 入栈
            st[++top] = s[i];
        }
    }
    return top === -1;
};

这个方法将匹配规则存入对象中,扩展性比第一种要好。

206. 反转链表

反转一个单链表。

示例:

输入: 1->2->3->4->5->NULL
输出: 5->4->3->2->1->NULL

进阶: 你可以迭代或递归地反转链表。你能否用两种方法解决这道题?

解法一 (迭代法)

/**
 * Definition for singly-linked list.
 * function ListNode(val) {
 *     this.val = val;
 *     this.next = null;
 * }
 */
/**
 * @param {ListNode} head
 * @return {ListNode}
 */
var reverseList = function(head) {
    if (!head) return head;
    var p = head, q = p.next;
    p.next = null;
    while (q) {
       var t = q.next;
        q.next = p;
        p = q;
        q = t;
    }
    return p;
};

图解
反转链表

我们可以看到,p是指向链表中第一个元素,q是指向链表中第二个元素,那么,我们要反转链表,原来第一个链表节点就应该变成链表最后一个节点,所以第一步我就将p.next指向null。第二步,我用一个临时变量t指向q.next,用意是用来储存还没反转链表的最开始的节点,然后,让q.next指向p,那么第一个节点,和第二个节点就局部反转了。第三步,我们还需要将p指向q,q再指向t,然后重复上述步骤,我们就可以将链表反转过来了。

解法二 (递归)

/**
 * Definition for singly-linked list.
 * function ListNode(val) {
 *     this.val = val;
 *     this.next = null;
 * }
 */
/**
 * @param {ListNode} head
 * @return {ListNode}
 */
var reverseList = function(head) {
    // 递归终止条件
    if (!head || !head.next) {
        return head;
    }
    var q = head.next;
    var p = reverseList(q);
    q.next = head;
    head.next = null;
    return p;
};

在这里,我想强调的是:任何的递归程序都可以改写成迭代形式,反之也成立。

104. 二叉树的最大深度

给定一个二叉树,找出其最大深度。 二叉树的深度为根节点到最远叶子节点的最长路径上的节点数。 说明: 叶子节点是指没有子节点的节点。

示例:

给定二叉树 [3,9,20,null,null,15,7]

    3
   / \
  9  20
    /  \
   15   7

返回最大深度为3。

解法一 (递归)

/**
 * Definition for a binary tree node.
 * function TreeNode(val) {
 *     this.val = val;
 *     this.left = this.right = null;
 * }
 */
/**
 * @param {TreeNode} root
 * @return {number}
 */
var maxDepth = function(root) {
    if (root) {
        var leftMax = maxDepth(root.left),
              rightMax = maxDepth(root.right);
        return Math.max(leftMax, rightMax) + 1;
    } else {
        return 0;
    }
};

这里使用的是递归方式。要求这个树最大的层数,那么,我们只需要求出左子树和右子树最大层数的一方 + 1即可,这里的1是指当前层级。

解法二 (迭代 + 栈 + 深度优先搜索)

/**
 * Definition for a binary tree node.
 * function TreeNode(val) {
 *     this.val = val;
 *     this.left = this.right = null;
 * }
 */
/**
 * @param {TreeNode} root
 * @return {number}
 */
var maxDepth = function(root) {
    if (!root) return 0;
    var st = [], top = -1;
     // 根节点入栈
     st[++top] = root;
     // 记录最大的层数
     var maxLevel = 1;
     root.level = 1;
     while (top != -1) {
         // 获取栈顶节点 && 出栈 (先根遍历)
         var node = st[top--],
             level = node.level;
         if (node.right) {
             st[++top] = node.right;
             st[top].level = level + 1;
             maxLevel < level + 1  && (maxLevel = level + 1);
         }
         if (node.left) {
             st[++top] = node.left;
             st[top].level = level + 1;
             maxLevel < level + 1 && (maxLevel = level + 1);
         }
     }
     return maxLevel;
};

这里会让每一个节点有一个额外的存储属性:level,来记录每一个节点的所在层数,然后与已知最大的层数对比即可得出结果。

为什么我只用===而不用==呢?

前言

在没接触eslint之前,我的代码格式可谓是随着心情走的,爱怎么写就怎么写。自从三年前,做一个Vue项目引入了eslint后,我的代码就变得规范多了,以至于现在还产生了强迫症。eslint里面有很多代码规范的标准。一般情况下,React项目代码遵循airbnb规范,Vue项目代码遵循standard规范。不过里面都有一条细则提及:始终使用 === 替代 ==。就这样,我将这个习惯沿用到现在,但我并没有去深究过,只知道这样做能减少代码中意想不到的出错。由于我本人并不甘心于“知其然而不知其所以然”,我想深究一番=====的区别。

===和==的区别

=====本质的区别是:前者是判断运算符两边的操作数是否严格等于,并不会做任何的数据类型转换;后者是判断运算符两边的操作数是否不严格等于,会适当地进行隐式类型转换。

== 使用细则

下面给出用==运算符比较时,两边操作数xy隐式类型转换的细则:

1.xy类型相同

1.1 若x为undefined类型, 则返回true。

console.log(undefined == undefined); // true

1.2 若x为null类型,则返回true。

console.log(null == null); // true

1.3 若x为number类型,且x和y只要有一者为NaN,则返回false。(NaN并不等于本身)

console.log(NaN == 0); // false
console.log(0 == NaN); // false
console.log(NaN == 1); // false
console.log(1 == NaN); // false
console.log(NaN == NaN); // false

1.4 若x为number类型,且x和y的数值相等,则返回true。若x和y的数值不相等,则返回false。

console.log(0 == 0); // true
console.log(1 == 1); // true
console.log(0 == 1); // false

1.5 若x为number类型,且x和y的值为+0或者-0,则返回true。

console.log(-0 == +0); // true
console.log(+0 == -0); // true
console.log(+0 == +0); // true
console.log(-0 == -0); // true

1.6 若x为string类型,当且仅当x和y字符序列完全相等的,则返回true。否则,返回false。

console.log('foo' == 'foo'); //true
console.log('foo' == 'bar'); // false

1.7 若x为boolean类型,当x和y二者同为true或者false时,则返回true。否则,返回false。

console.log(true == true); // true
console.log(false == false); // true
console.log(true == false); // false
console.log(false == true); // false

1.8 若x为object类型,当且仅当x和y二者是同一引用,则返回true。否则,返回false。

var x = {}, y = {}, z = x;
console.log(x == y); // false
console.log(x == z); // true
console.log(y == z); // false
console.log(x == {}); // false
console.log({} == y); // false
console.log({} == {}); // false

2.xy类型不相同

2.1 若x为null,y为undefined,或者x为undefined,y为null,则返回true。

console.log(null == undefined); // true
console.log(undefined == null); // true

2.2 若x与y二者,一个为number类型,另一个为string类型,则先将string类型隐式转换为number类型,再进行数值比较。

console.log('123' == 123); // true  <=> Number('123') == 123  <=> 123 == 123
console.log(123 == '123'); // true  <=> 123 == Number('123')  <=> 123 == 123
console.log('abc' == 123) // false  <=> Number('abc') == 123  <=> NaN == 123

2.3 若x与y二者,若存在一个为boolean类型,则先将boolean类型隐式转换为number类型,再进行数值比较。

console.log(false == 0); // true  <=>  Number(false) == 0  <=>  0 == 0
console.log(true == 1); // true  <=>  Number(true) == 1 <=>  1 == 1
console.log(false == 2); // false  <=>  Number(false) == 2  <=> 0 == 2
console.log(true == 2); // false  <=>  Number(true) == 2  <=> 1 == 2

2.4 若x与y二者,一个为number类型或者string类型或者boolean类型,另一个为object类型时,object类型会隐式调用valueOf或者toString方法,再进行比较。

var foo = { bar: 0 };
console.log(foo == 2); // false <=> foo.toString() == 2 <=> '[object Object]' == 2  <=> Number('[object Object]') == 2  <=> NaN == 2
console.log(foo == '2'); // false <=> foo.toString() == '2' <=> '[object Object]' == '2'
console.log(foo == '[object Object]'); // true

ps:我们可以重写valueOf或者toString方法来覆盖原生方法默认的行为,来达到最佳的对比效果。

var foo = { bar: 0 };
foo.toString = () => '2'; // foo.valueOf = () => 2; 若两者都重写了,以valueOf为准
console.log(foo == 2); // true;
console.log(foo == '2'); // true

2.5 其余情况返回false。

console.log('123abc' == 123); // false
console.log(null == false); // false
console.log(undefined == false); // false
...

接着,我们探讨一下一个有趣的题目:[] == ![] // -> true,利用上面罗列的细则,我们一步步推导。

// ![]返回是一个boolean类型 -> !Boolean([]) -> !true -> false
[] == ![]  <=> [] == false
// object类型和boolean对比,先转换对象 ->  [] -> [].toString() -> ''
[] == false <=> '' == false
// ==两边操作数出现boolean类型,我们先将它做数字类型转换 -> false -> Number(false) -> 0
'' == false <=> '' == 0
// ==两边操作数出现string类型和number类型,我们先将string类型做数字类型转换 -> '' -> Number('') -> 0
'' == 0 <=> 0 == 0
// 所以最后得出的结果为 true。

可见,这些细则已经足够难记,倘若某一天我们还没去注意怎么使用==,程序中难免会出现很多意想不到的bug。为了尽量避免出错,我实际开发中,一般只会使用===,而不会使用==

=== 使用细则

接下来,我们看看,使用===的细则,这里同样用x和y代表运算符两边的操作数。

  • 若x和y类型不同,直接返回false。
console.log(undefined === null); // false
console.log(1 === true); // false
console.log(0 === false); // false
console.log(1 === '1'); // false
console.log(0 === '0'); // false
console.log('1' === true); // false
console.log('0' === false); // false
console.log(0 === []); // false
console.log(false === []); // false
console.log('' === []); // false
  • 若x和y类型相同,若都为基本类型,对比二者数值是否相等;若为引用类型,对比两者引用地址是否是同一地址。
var a = {}, b = {}, c = a;
console.log(undefined === undefined); // true
console.log(null === null); // true
console.log(0 === 0); // true
console.log(0 === 1); // false
console.log('0' === '0'); // true
console.log('0' === '1'); false
console.log(false === false); // true;
console.log(true === false); // false
console.log({} === {}); // false
console.log(a === b); // false
console.log(a === c); // true;
console.log(b === c); // false
...

我们可以看到,使用===仅有两条细则,完全不涉及到一些隐式数据类型转换,大大提高了代码的可调试性和可预见性,而且易用性远比==好。所以,在日常开发中,我强烈推荐使用===,尽可能少用==。或许我的推荐显得不怎么权威,但是,这个细则已经写入了很多JavaScript代码规范了。

JavaScript 图

背景

我们可以将复杂的网络看做一张图,数据在网络中以 点对点 形式传输。图通常用来表示和存储具有“多对多”关系的数据结构,是数据结构中非常重要的一种结构。图是众多数据中比较复杂的一种,本章,我们将介绍,如何用 JavaScript 表示图,如何实现重要的图算法。

定义

图由 的集合和 顶点 的集合组成。我们在**地图上看到各个省道、国道、高速、铁路等连接两个地方的道路,在图里面都可以看做 ,各个交通枢纽,例如北京、上海、深圳、广州等城市,在图中可以看做是 顶点

边由顶点 对 (v1,v2) 定义,v1 和 v2 分别是图中的两个顶点。顶点也有权重,也称为成本。如果一个 图的顶点对是有序的,则可以称之为有向图。在对有向图中的顶点对排序后,便可以在两个顶点之间绘制一个箭头。有向图表明了顶点的流向。计算机程序中用来表明计算方向的 流程图就是一个有向图的例子。下面展示了一个有向图。

有向图

如果图是无序的,则称之为无序图,或无向图。下面展示了一个无序图。

无向图

图中的一系列顶点构成路径,路径中所有的顶点都由边连接。路径的长度用路径中第一个顶点到最后一个顶点之间边的数量表示。由指向自身的顶点组成的路径称为环,环的长度为 0。

圈是至少有一条边的路径,且路径的第一个顶点和最后一个顶点相同。无论是有向图还是无向图,只要是没有重复边或重复顶点的圈,就是一个简单圈。除了第一个和最后一个顶点以外,路径的其他顶点有重复的圈称为平凡圈。

如果两个顶点之间有路径,那么这两个顶点就是强连通的,反之亦然。如果有向图的所有的顶点都是强连通的,那么这个有向图也是强连通的。

顶点(Vertex)类实现

创建图类的第一步就是要创建一个 Vertex 类来保存顶点和边。这个类的作用与链表和二叉搜索树的 Node 类一样。Vertex 类有两个数据成员:一个用于标识顶点,另一个是表明这个顶点是否被访问过的布尔值。它们分别被命名为 label 和 hadVisited。这个类只需要一个函数,那就是为顶点的数据成员设定值的构造函数。Vertex 类的代码如下所示:

// 顶点构造函数
function Vertex (label) {
    this.label = label;
    this.hadVisited = false;
}

边表示

图的实际信息都保存在边上面,因为它们描述了图的结构。我们很容易像之前提到的那样用二叉树的方式去表示图,这是不对的。二叉树的表现形式相当固定,一个父节点只能有两个子节点,而图的结构却要灵活得多,一个顶点既可以有一条边,也可以有多条边与它相连。

我们将表示图的边的方法称为邻接表。这种方法将边存储为由顶点的相邻顶点列表构成的数组,并以此顶点作为索引。使用这种方案,当我们在程序中引用一个顶 点时,可以高效地访问与这个顶点相连的所有顶点的列表。比如,如果顶点 2 与顶点 1、 3相连,并且它存储在数组中索引为 2 的位置,那么,访问这个元素,我们可以访 问到索引为 2 的位置处由顶点 1、3组成的数组。请参考下图:

邻接表

图(Vertex)类实现

确定了如何在代码中表示图之后,构建一个表示图的类就很容易了。下面是第一个 Graph类的定义:

[完整代码地址]

// 图构造函数
function Graph (v) {
    this.vertices = v;
    this.edges = 0;
    this.adj = [];
    this.init();
}

// 数据初始化
Graph.prototype.init  = function () {
    for (var i = 0; i < this.vertices; i++) {
        this.adj[i] = [];
    }
}

// 添加边
Graph.prototype.addEdge = function (v1, v2) {
    this.adj[v1].push(v2);
    this.adj[v2].push(v1);
    this.edges++;
}

// 输出图信息
Graph.prototype.showGraph = function () {
    for (var i = 0; i < this.vertices; ++i) {
        var logMsg = i + ' -->';
        for (var j = 0; j < this.vertices; j++) {
            if (this.adj[i][j]) { 
                logMsg += ' ' + this.adj[i][j]
            }
        }
        console.log(logMsg);
    }
}

图(Graph)类测试

var graph = new Graph(6);

graph.addEdge(0, 4);
graph.addEdge(1, 2);
graph.addEdge(1, 4);
graph.addEdge(2, 3);
graph.addEdge(3, 4);
graph.addEdge(3, 5);

graph.showGraph();

运行结果

0 --> 4
1 --> 2 4
2 --> 1 3
3 --> 2 4 5
4 --> 0 1 3
5 --> 3

图的遍历

深度优先搜索

深度优先搜索包括从一条路径的起始顶点开始追溯,直到到达最后一个顶点,然后回溯, 继续追溯下一条路径,直到到达最后的顶点,如此往复,直到没有路径为止。这不是在搜索特定的路径,而是通过搜索来查看在图中有哪些路径可以选择。

// 深度优先搜索
Graph.prototype.dfs = function (v) {
    var st = [], top = -1;
    var visited = [];
    st[++top] = v;
    visited[v] = true;
    while (top !== -1) {
        var vertex = st[top--];
        console.log('Visited --> ' + vertex);
        for (var i = this.adj[vertex].length - 1; i >= 0; i--) {
            if (!visited[this.adj[vertex][i]]) {
                st[++top] = this.adj[vertex][i];
                visited[this.adj[vertex][i]] = true;
            }
        }
    }
}

广度优先搜索

广度优先搜索从第一个顶点开始,尝试访问尽可能靠近它的顶点。本质上,这种搜索在图上是逐层移动的,首先检查最靠近第一个顶点的层,再逐渐向下移动到离起始顶点最远的层.

Graph.prototype.bfs = function (v) {
    var rear = -1, front = -1;
    var queue = [], visited = [];
    queue[++rear] = v;
    visited[v] = true;
    while (rear != front) {
        var vertex = queue[++front];
        console.log('Visited --> ' + vertex);
        for (var i = 0; i < this.adj[vertex].length; i++) {
            var tempVertex = this.adj[vertex][i];
            if (!visited[tempVertex]) {
                queue[++rear] = tempVertex;
                visited[tempVertex] = true;
            }
        }
    }
}

测试代码

var graph = new Graph(6);

graph.addEdge(0, 4);
graph.addEdge(1, 2);
graph.addEdge(1, 4);
graph.addEdge(2, 3);
graph.addEdge(3, 4);
graph.addEdge(3, 5);

console.log('=============== dfs ===============');
graph.dfs(0);
console.log('=============== bfs ===============');
graph.bfs(0);

运行结果

=============== dfs ===============
Visited --> 0
Visited --> 4
Visited --> 1
Visited --> 2
Visited --> 3
Visited --> 5
=============== bfs ===============
Visited --> 0
Visited --> 4
Visited --> 1
Visited --> 3
Visited --> 2
Visited --> 5

查找最短路径

图最常见的操作之一就是寻找从一个顶点到另一个顶点的最短路径。考虑下面的例子:在国庆假期中,你将在两个星期的时间里游历 10 个大旅游城市,去参观各种名胜古迹。你希望通过最短路径算法,找出开车游历这 10 个城市行驶的最小里程数。另一个最短路径问题涉及创建一个计算机网络时的开销,其中包括两台电脑之间传递数据的时间,或者两台电脑建立和维护连接的成本。最短路径算法可以帮助确定构建此网络的最有效方法。

广度优先搜索对应的最短路径

在执行广度优先搜索时,会自动查找从一个顶点到另一个相连顶点的最短路径。例如,要查找从顶点 A 到顶点 D 的最短路径,我们首先会查找从 A 到 D 是否有任何一条单边路径, 接着查找两条边的路径,以此类推。这正是广度优先搜索的搜索过程,因此我们可以轻松地修改广度优先搜索算法,找出最短路径。

参考链接

最短路径—Dijkstra算法和Floyd算法
图的四种最短路径算法
拓扑排序

JavaScript 字典

前言

JavaScript 的 Object 类就是以字典的形式设计的。

定义

字典是一种以 键 - 值 对形式存储数据的数据结构,就像电话号码簿里的名字和电话号码一样。要找一个电话时,先找名字,名字找到了,紧挨着它的电话号码也就找到了。这里的键是指你用来查找的东西,值是查找得到的结果。

说明

这章不打算展开,有兴趣的同学可以看《数据结构与算法JavaScript描述》的第七章 - 字典。作者虽然用 Dictionary 类将数组封装了一次,但在我看来作用不大,远没有原生的 Object 类来得直接,同学们大可以将 JavaScript 中的 Object 类理解为字典(可能这个理解很狭隘)。

LeetCode(力扣)答案解析(十)

89. 格雷编码

格雷编码是一个二进制数字系统,在该系统中,两个连续的数值仅有一个位数的差异。

给定一个代表编码总位数的非负整数 n,打印其格雷编码序列。格雷编码序列必须以 0 开头。

示例1:

输入: 2
输出: [0,1,3,2]
解释:
00 - 0
01 - 1
11 - 3
10 - 2

对于给定的 n,其格雷编码序列并不唯一。
例如,[0,2,3,1] 也是一个有效的格雷编码序列。

00 - 0
10 - 2
11 - 3
01 - 1

示例2:

输入: 0
输出: [0]
解释: 我们定义格雷编码序列必须以 0 开头。
     给定编码总位数为 n 的格雷编码序列,其长度为 2^n。当 n = 0 时,长度为 2^0 = 1
     因此,当 n = 0 时,其格雷编码序列为 [0]

解法一(公式法)

/**
 * @param {number} n
 * @return {number[]}
 */
var grayCode = function (n) {
    var res = [];
    for (var i = 0, len = Math.pow(2, n); i < len; i++) {
        res.push((i >> 1) ^ i);
    }
    return res;
};

解析一:

格雷码是一种循环二进制单位距离码,主要特点是 两个相邻数的代码只有一位二进制数不同 的编码,格雷码的处理主要是位操作 Bit Operation。这里的解题思路依据主要是格雷编码第 n 项等于 n / 2 ^ n

更多解法,参考链接:

格雷码那点事——递归非递归实现。
LeetCode(89):格雷编码。

46. 全排列

给定一个没有重复数字的序列,返回其所有可能的全排列。

示例:

输入: [1,2,3]
输出:
[
  [1,2,3],
  [1,3,2],
  [2,1,3],
  [2,3,1],
  [3,1,2],
  [3,2,1]
]

解法一(递归)

/**
 * @param {number[]} nums
 * @return {number[][]}
 */
var permute = function(nums) {
    var ans = [];
    var len = nums.length;
    if (len === 0) return ans;
    permutation(nums, 0, len - 1, ans);
    return ans;
};

function permutation (nums, i, j, ans) {
    if (i === j) {
        ans.push(nums.slice(0, nums.length));
    } else {
        var temp;
        for (var k = i; k <= j; k++) {
            temp = nums[i];
            nums[i] = nums[k];
            nums[k] = temp;
            permutation(nums, i + 1, j, ans);
            temp = nums[i];
            nums[i] = nums[k];
            nums[k] = temp;
        }
    }
}

78. 子集

给定一组不含重复元素的整数数组 nums,返回该数组所有可能的子集(幂集)。

说明:解集不能包含重复的子集。

示例:

输入: nums = [1,2,3]
输出:
[
  [3],
  [1],
  [2],
  [1,2,3],
  [1,3],
  [2,3],
  [1,2],
  []
]

解法一:

/**
 * @param {number[]} nums
 * @return {number[][]}
 */
var subsets = function(nums) {
    var ans = [],
        out = [];
    nums.sort(compare);
    getSubsets(nums, 0, out, ans);
    return ans;
};

function getSubsets (nums, pos, out, ans) {
    ans.push(out.slice(0, out.length));
    for (var i = pos; i < nums.length; i++) {
        out.push(nums[i]);
        getSubsets(nums, i + 1, out, ans);
        out.pop();
    }
}

function compare (a, b) {
    return a - b;
}

LeetCode(力扣)答案解析(六)

121. 买卖股票的最佳时机

给定一个数组,它的第 i 个元素是一支给定股票第 i 天的价格。

如果你最多只允许完成一笔交易(即买入和卖出一支股票),设计一个算法来计算你所能获取的最大利润。

注意你不能在买入股票前卖出股票。

示例1:

输入: [7,1,5,3,6,4]
输出: 5
解释: 在第 2 天(股票价格 = 1)的时候买入,在第 5 天(股票价格 = 6)的时候卖出,最大利润 = 6-1 = 5 
     注意利润不能是 7-1 = 6, 因为卖出价格需要大于买入价格。

示例2:

输入: [7,6,4,3,1]
输出: 0
解释: 在这种情况下, 没有交易完成, 所以最大利润为 0

解法一(暴力法):

/**
 * @param {number[]} prices
 * @return {number}
 */
var maxProfit = function(prices) {
    var len = prices.length,
          ans = 0;
    for (var i = 0; i < len - 1; i++) {
        for (var j = i + 1; j < len; j++) {
            var res = prices[j] - prices[i];
            res > ans && (ans = res);
        }
    }
    return ans;
};

解析:

这种解法的时间复杂度为O(n2),提交竟然没有超时,很让人惊讶。暴力法的思路很简单,就是逐个元素,用其后的元素减去这个元素,记录最大值。

解法二(动态规划):

/**
 * @param {number[]} prices
 * @return {number}
 */
var maxProfit = function(prices) {
    var min = prices[0] || 0,
          max = 0;
    for (var i = 1, len = prices.length; i < len; i++) {
        if (prices[i] < min) {
            min = prices[i];
        } else if (prices[i] - min > max) {
            max = prices[i] - min;
        }
    }
    return max;
};

解析:

这种解法的时间复杂度为O(n),性能有了质的提高。动态规划的解法,思路也是比较简单,遍历prices数组种的数字,并记录最小值,若找到,直接更新最小值;若没找到,将当前遍历的数减去最小值,再和已知最大的值对比,然后重新记录最大值。

141. 环形链表

给定一个链表,判断链表中是否有环。

为了表示给定链表中的环,我们使用整数 pos 来表示链表尾连接到链表中的位置(索引从 0 开始)。 如果 pos 是 -1,则在该链表中没有环。

示例1:

输入:head = [3,2,0,-4], pos = 1
输出:true
解释:链表中有一个环,其尾部连接到第二个节点。

1

示例2:

输入:head = [1,2], pos = 0
输出:true
解释:链表中有一个环,其尾部连接到第一个节点。

2

示例3:

输入:head = [1], pos = -1
输出:false
解释:链表中没有环。

3

题解 (双指针):

/**
 * Definition for singly-linked list.
 * function ListNode(val) {
 *     this.val = val;
 *     this.next = null;
 * }
 */

/**
 * @param {ListNode} head
 * @return {boolean}
 */
var hasCycle = function(head) {
    if (head == null || head.next == null) {
        return false;
    }
    var slow = head,
        fast = head.next;
    while (slow != fast) {
        if (fast == null || fast.next == null) {
            return false;
        }
        slow = slow.next;
        fast = fast.next.next;
    }
    return true;
};

解析:

这种解题思路,非常巧妙。我们用两个指针来遍历链表,其中一个指针比另外一个指针移动快两倍,这样,当快的指针遍历了两次链表,慢的指针就刚好遍历一次链表,若链表有环,最后slowfast必然相等。否则,链表就不存在环。

160. 相交链表

编写一个程序,找到两个单链表相交的起始节点。

例如,下面的两个链表:

A:          a1  a2
                   
                     c1  c2  c3
                               
B:     b1  b2  b3

在节点 c1 开始相交。

注意:

  • 如果两个链表没有交点,返回 null.
  • 在返回结果后,两个链表仍须保持原有的结构。
  • 可假定整个链表结构中没有循环。
  • 程序尽量满足 O(n) 时间复杂度,且仅用 O(1) 内存。

题解:

/**
 * Definition for singly-linked list.
 * function ListNode(val) {
 *     this.val = val;
 *     this.next = null;
 * }
 */

/**
 * @param {ListNode} headA
 * @param {ListNode} headB
 * @return {ListNode}
 */
var getIntersectionNode = function(headA, headB) {
    var p1 = headA,
        p2 = headB;
    while (headA != null && headB != null) {
        headA = headA.next;
        headB = headB.next;
    }
    while (headA != null) {
        headA = headA.next;
        p1 = p1.next;
    }
    while (headB != null) {
        headB = headB.next;
        p2 = p2.next;
    }
    while (p1 != null && p2 != null) {
        if (p1 === p2) {
            return p1;
        }
        p1 = p1.next;
        p2 = p2.next;
    }
    return null;
};

解析:

这里思路理解起来也不难。第一个循环只要是找出较长的链表,第二和第三个循环,是为了纠正两个链表的长度,保证在进入第四个循环前,p1和p2两个链表长度一样。那么,在进入第四个循环体内,一切对比就变得非常轻松。

Cornerstone如何全选Missing文件?

Cornerstone是Mac OS下最流行的SVN可视化工具,而且是要收费的。但是,我这两天用起来,觉得真不如windows下的TortoiseSVN好用,可能是使用姿势不对。
今天总结一个坑,当我批量重名文件的时候,SVN是当新的文件名是增加(Add状态),被重命名的那个新的文件是丢失(Missing状态),而我发现用Cornerstone提交代码的时候,Missing状态的文件并没有自动添加到Commit文件列表中,需要手动地逐个选中,右击鼠标,按delete选项来将状态由Missing改成Delete。这么问题来了,几个Missing文件这样操作可以,但几百上千个文件怎么办?逐个手动选中?我在Cornerstone界面上,找了半天,都没找到能全选Missing文件的Checkbox。折腾半天后,我的部门老大告诉了我一个快捷键能全选Missing文件的,具体如下:

非标准键盘(不是苹果键盘)

win + alt + a

标准键盘

option + command + a

这时,你会发现所有Missing列表中的文件或者文件夹都会被选中了。右击,选择菜单最底下的delete选项,就能批量将Missing状态的文件转化为Delete状态。

JavaScript 排序算法

前言

对计算机中存储的数据执行的两种最常见操作是 排序检索,自从计算机产业诞生以来便是如此。这也意味着排序和检索在计算机科学中是被研究得最多的操作。前面章节提到的许多数据结构,都对排序和查找算法进行了专门的设计,以使对其中的数据进行操作时更简洁高效。(本文最后提供动画演示链接)

定义

排序是计算机内经常进行的一种操作,其目的是将一组“无序”的记录序列调整为“有序”的记录序列。分内部排序外部排序,若整个排序过程不需要访问外存便能完成,则称此类排序问题为内部排序。反之,若参加排序的记录数量很大,整个序列的排序过程不可能在内存中完成,则称此类排序问题为外部排序。内部排序的过程是一个逐步扩大记录的有序序列长度的过程。本章内容只讨论内部排序。

基本排序算法

基本排序算法其核心**是指对一组数据按照一定的顺序重新排列。重新排列时用到的技术是一组嵌套的 for循环。其中外循环会遍历数组的每一项,内循环则用于比较元素。这些算法非常逼真地模拟了人类在现实生活中对数据的排序,例如纸牌玩家在处理手中的牌时对纸牌进行排序,或者教师按照字母顺序或者分数对试卷进行排序。

冒泡排序

为什么先讲冒泡排序呢?因为它是出现在很多C语言教材书中,介绍的第一种排序算法,也是最慢的排序算法之一。冒泡排序在实现上是非常容易的,但是比较难理解(至少对于很多初学者而言)。

之所以叫冒泡排序是因为使用这种排序算法排序时,数据值会像气泡一样从数组的一端漂浮到另一端。假设正在将一组数字按照升序排列,较大的值会浮动到数组的右侧,而较小的值则会浮动到数组的左侧。之所以会产生这种现象是因为算法会多次在数组中移动,比 较相邻的数据,当左侧值大于右侧值时将它们进行互换。

[完整代码地址]

/**
 * 冒泡排序
 * @param {array}  arr   待排序数组
 * @param {string} type  排序类型:asc / desc
 */
function bubble (arr, type) {
    // 记录数组长度
    var len = arr.length;
    // 标识是否有交换
    var hasExchange = false;
    // 外层循环遍历数组每一个元素
    for (var i = 0; i < len - 1; i++) {
        // 重置
        hasExchange = false;
        // 内层循环用于比较元素
        for (var j = 0; j < len - i - 1; j++) {
            if ((!type || type === 'asc') && arr[j] > arr[j + 1]) { // 顺序排序
                hasExchange = true;
                swap(arr, j, j+1);
            } else if (type === 'desc' && arr[j] < arr[j + 1]) {    // 逆序排序
                hasExchange = true;
                swap(arr, j, j+1);
            }
        }
        // 如果没有任何交换,直接跳出循环
        if (!hasExchange) {
            break;
        }
    }
}

/**
 * 交换数组里面的两个元素
 * @param {array} arr 待交换元素的数组
 * @param {number} i  待交换元素下标1
 * @param {number} j  待交换元素下标2
 */
function swap (arr, i, j) {
    var temp = arr[i];
    arr[i] = arr[j];
    arr[j] = temp;
}

注意: 这里的冒泡排序是较简单版本复杂一点,主要是做了功能拓展和性能优化。

选择排序

选择排序从数组的开头开始,将第一个元素和其他元素进行比较。检查完所有元素后,最小的元素会被放到数组的第一个位置,然后算法会从第二个位置继续。这个过程一直进行,当进行到数组的倒数第二个位置时,所有的数据便 完成了排序。

选择排序会用到嵌套循环。外循环下标从数组的第一个元素移动到倒数第二个元素;内循环下标从第二个数组元素移动到最后一个元素,查找比当前外循环所指向的元素小的元素。每次内循环迭代后,数组中最小的值都会被赋值到合适的位置。

[完整代码地址]

/**
 * 选择排序
 * @param {array}  arr 
 * @param {string} type  asc / desc
 */
function select (arr, type) {
    // 获取数组长度
    var len = arr.length;
    // 外循环遍历数组元素
    for (var i = 0; i < len - 1; i++) {
        // 临时最小值下标
        let idx = i;
        // 内循环比较数组元素大小
        for (var j = i + 1; j < len; j++) {
            if ((!type || type === 'asc') && arr[idx] > arr[j]) { // 顺序
                idx = j;
            } else if (type === 'desc' && arr[idx] < arr[j]) {    // 逆序
                idx = j
            }
        }
        // 如果下标有变
        if (idx !== i) {
            swap(arr, idx, i);      
        }
    }
}

/**
 * 交换数组里面的两个元素
 * @param {array} arr 待交换元素的数组
 * @param {number} i  待交换元素下标1
 * @param {number} j  待交换元素下标2
 */
function swap (arr, i, j) {
    var temp = arr[i];
    arr[i] = arr[j];
    arr[j] = temp;
}

注意: 同样的,这里的选择排序也是较简单版本复杂一点,主要也是做了功能拓展和性能优化。

插入排序

插入排序类似于人类按数字或字母顺序对数据进行排序。例如玩扑克牌的时候,我们会将第二张牌跟第一张牌比较,若比第一张牌小,则移动到第一张牌前面;然后第三张牌跟第二张牌比较,若第三张牌比第二张小,则再与第一张牌比较,若还比第一张牌小,则放在第一张牌的前面,否则,放在第二张牌前面;后面的牌以此类推。

插入排序有两个循环。外循环将数组元素挨个移动,而内循环则对外循环中选中的元素及 它后面的那个元素进行比较。如果外循环中选中的元素比内循环中选中的元素小,那么数组元素会向右移动,为内循环中的这个元素腾出位置。

[完整代码地址]

/**
 * 插入排序
 * @param {array}  arr   待排序数组
 * @param {string} type  asc / desc
 */
function insert (arr, type) {
    // 记录数组长度
    var len = arr.length;
    // 外循环记录已排序最后下标
    for (var i = 1; i < len; i++) {
        // 临时变量
        var temp = arr[i];
        // 内循环进行数组元素比较
        for (var j = i; j > 0; j--) {
            if ((!type || type === 'asc') && arr[j - 1] > temp) { // 顺序排序
                // 往后挪一位
                arr[j] = arr[j - 1];
                continue;
            } else if (type === 'desc' && arr[j - 1] < temp) {    // 逆序排序
                // 往后挪一位
                arr[j] = arr[j - 1];
                continue;
            }
            break;
        }
        // 放入合适的位置
        arr[j] = temp;
    }
}

基本排序算法性能对比

虽说,这三种排序算法的复杂度非常相似,都是O(n^2),从理论上来说,它们的执行效率也应该差不多。要确定这三种算法的性能差异,我们可以使用一个非正式的计时系统来比较它们对数据集合进行排序所花费的时间。能够对算法进行计时非常重要,因为,对 100 个或 1000 个元素进 行排序时,你看不出这些排序算法的差异。但是如果对上百万个元素进行排序,这些排序算法之间可能存在巨大的不同。

在比较这三个基本排序算法之前,我们需要一个方法,能生成指定长度的、随机初始化元素的数值数组,具体代码如下:

[完整代码地址]

/**
 * 随机生成指定长度的数值数组
 * @param {number} len 数组长度
 */
function RandomArray (len) {
    var res = [];
    for (var i = 0; i < len; i++) {
        res.push(parseFloat((Math.random() * len).toFixed(2)));
    }
    return res;
}

测试实例代码

var arr = [],
    range = 1000;
arr = RandomArray(range);
var start = new Date().getTime();
bubble(arr);
var end = new Date().getTime();
console.log('冒泡排序 ', end - start, ' 毫秒');
arr = RandomArray(range);
var start = new Date().getTime();
select(arr);
var end = new Date().getTime();
console.log('选择排序 ', end - start, ' 毫秒');
arr = RandomArray(range);
var start = new Date().getTime();
insert(arr);
var end = new Date().getTime();
console.log('插入排序 ', end - start, ' 毫秒');

1000数量级(3次)运行结果

// 第一次
冒泡排序  4  毫秒
选择排序  4  毫秒
插入排序  2  毫秒
// 第二次
冒泡排序  3  毫秒
选择排序  3  毫秒
插入排序  1  毫秒
// 第三次
冒泡排序  2  毫秒
选择排序  3  毫秒
插入排序  1  毫秒

从这三次运行结果来看,插入排序性能略胜一筹,而冒泡排序和选择排序性能相当。

10000数量级(3次)运行结果

// 第一次
冒泡排序  225  毫秒
选择排序  154  毫秒
插入排序  49  毫秒
// 第二次
冒泡排序  238  毫秒
选择排序  165  毫秒
插入排序  39  毫秒
// 第三次
冒泡排序  237  毫秒
选择排序  162  毫秒
插入排序  37  毫秒

从上面运行结果可以看出,当排序数量级到达 10000 级别的时候,三种排序性能差距也在增大。可以看出,插入排序性能最佳,其次是选择排序,最慢的是冒泡排序。

100000数量级(3次)运行结果

// 第一次
冒泡排序  25995  毫秒
选择排序  17455  毫秒
插入排序  3626  毫秒
// 第二次
冒泡排序  24643  毫秒
选择排序  16849  毫秒
插入排序  3636  毫秒
// 第三次
冒泡排序  25384  毫秒
选择排序  16940  毫秒
插入排序  3873  毫秒

同理的,当排序数量级到达 100000 数量级的时候,插入排序消耗时间与其他两种排序算法不在一个数量级上。而三次比较下,冒泡排序需要花费的平均时间竟然高达25秒左右,而插入排序花费的平均时间只要3.7秒左右。

高级排序算法

在实际应用中,业务排序数量级可能到达几百上千万的级别,显然,基本排序算法并不能胜任这些场景。那么,下面我们将介绍高级数据排序算法,它们通常被认为是处理大型数据集的最高效排序算法,它们处理的数据集可以达到上百万个元素,而不仅仅是几百个或者几千个。

希尔排序

希尔排序是以它的创造者(Donald Shell) 命名的。这个算法在插入排序的基础上做了很大的改善。希尔排序的核心理念与插入排序不同,它会首先比较距离较远的元素,而非相邻的元素。和简单地比较相邻元素相比,使用这种方案可以使离正确位置很远的元素更快地回到合适的位置。当开始用这个算法遍历 数据集时,所有元素之间的距离会不断减小,直到处理到数据集的末尾,这时算法比较的就是相邻元素了。

希尔排序的工作原理是,通过定义一个间隔序列来表示在排序过程中进行比较的元素之间有多远的间隔。我们可以动态定义间隔序列,不过对于大部分的实际应用场景,算法要用到的间隔序列可以提前定义好。有一些公开定义的间隔序列,使用它们会得到不同的结果。在这里我们用到了 Marcin Ciura 在他 2001 年发表的论文“Best Increments for the Average Case of Shell Sort”中定义的间隔序列。这个间隔序列分别是:701, 301, 132, 57, 23, 10, 4, 1。

[完整代码地址]

/**
 * 希尔排序
 * @param {array}  arr   待排序数组
 * @param {string} type  asc / desc
 */
function shell (arr, type) {
    // 声明间隔
    var gaps = [5, 3, 1];
    // 记录数组长度
    var len = arr.length;
    // 最外层循环遍历间隔数组
    for (var i = 0, len0 = gaps.length; i < len0; i++) {
        // 临时变量
        var gap = gaps[i];
        // 第二层循环按间隔遍历数组
        for (var j = gap; j < len; j++) {
            // 临时变量
            var temp = arr[j];
            // 第三层循环比较数组元素大小
            for (var k = j; k >= gap; k -= gap) {
                if ((!type || type === 'asc') && arr[k - gap] > temp) { // 顺序排序
                    // 往后挪gap位
                    arr[k] = arr[k - gap];
                    continue;
                } else if (type === 'desc' && arr[k - gap] < temp) {    // 逆序排序
                    // 往后挪gap位
                    arr[k] = arr[k - gap];
                    continue;
                }
                break;
            }
            arr[k] = temp;
        }
    }
}

归并排序

归并排序的命名来自它的实现原理:把一系列排好序的子序列合并成一个大的完整有序序列。从理论上讲,这个算法很容易实现。我们需要两个排好序的子数组,然后通过比较数据大小,先从最小的数据开始插入,最后合并得到第三个数组。然而,在实际情况中,归并排序还有一些问题,当我们用这个算法对一个很大的数据集进行排序时,我们需要相当大的空间来合并存储两个子数组。就现在来讲,内存不那么昂贵,空间不是问题,因此,我们需要关注的是它和其他排序算法的执行效率。

递归实现

[完整代码地址]

/**
 * 归并排序 - 递归实现
 * @param {array}  arr   待排序数组
 * @param {string} type  asc / desc
 */
function merge_recursive (arr, type) {
    // 记录数组长度
    var len = arr.length;
    // 归并排序
    merge_sort(arr, 0, len - 1, type);
}

/**
 * 归并排序
 * @param {array}  arr  数组
 * @param {number} low  最小下标
 * @param {number} high 最大下标
 * @param {string} type 排序类型
 */
function merge_sort(arr, low, high, type) {
    if (low < high) {
        var mid = Math.floor((low + high) / 2);
        merge_sort(arr, low, mid, type);
        merge_sort(arr, mid + 1, high, type);
        merge(arr, low, mid, high, type);
    }
}

/**
 * 按大小顺序合并数组
 * @param {array}  arr  数组
 * @param {number} low  最小下标
 * @param {number} mid  中间下标
 * @param {number} high 最大下标
 * @param {string} type 排序类型
 */
function merge (arr, low, mid, high, type) {
    var left = low,
        right = mid + 1,
        t = 0;
    var tempArr = [];
    while (left <= mid && right <= high) {
        if (!type || type === 'asc') { //  顺序排序
            if (arr[left] < arr[right]) {
                tempArr[t++] = arr[left++];
            } else {
                tempArr[t++] = arr[right++];
            }
        } else if (type === 'desc') {  // 逆序排序
            if (arr[left] > arr[right]) {
                tempArr[t++] = arr[left++];
            } else {
                tempArr[t++] = arr[right++];
            }
        }
        
    }
    while (left <= mid) {
        tempArr[t++] = arr[left++];
    }
    while (right <= high) {
        tempArr[t++] = arr[right++];
    }
    while (t > 0) {
        arr[high--] = tempArr[--t];
    }
}

注意:递归方式的归并排序是自顶向下的,而下面要介绍的迭代方式,是自底向上的。

迭代实现

[完整代码地址]

/**
 * 归并排序 - 迭代实现
 * @param {array}  arr   待排序数组
 * @param {string} type  asc / desc
 */
function merge_iteration (arr, type) {
    // 记录数组长度
    var len = arr.length;
    // 若数组为空或者只有一个元素
    if (len < 2) {
        return;
    }
    // 归并跨度
    var span = 2;
    // 归并
    while (span < len) {
        // 遍历数组
        var i = 0;
        while (i + span <= len) {
            var mid = i + Math.floor(span / 2) - 1;
            merge(arr, i, mid, i + span - 1, type)
            i += span;
        }
        // 遗漏修正
        if (i < len) {
            var temp = i + Math.floor(span / 2) - 1,
                mid = Math.min(temp, len - 1);
            merge(arr, i, mid, len - 1, type);
        }
        // 归并跨度翻倍
        span *= 2;
    }
    // 顶层修正
    merge(arr, 0, span / 2 - 1, len - 1, type);
}


/**
 * 按大小顺序合并数组
 * @param {array}  arr  数组
 * @param {number} low  最小下标
 * @param {number} mid  中间下标
 * @param {number} high 最大下标
 * @param {string} type 排序类型
 */
function merge (arr, low, mid, high, type) {
    var left = low,
        right = mid + 1,
        t = 0;
    var tempArr = [];
    while (left <= mid && right <= high) {
        if (!type || type === 'asc') { //  顺序排序
            if (arr[left] < arr[right]) {
                tempArr[t++] = arr[left++];
            } else {
                tempArr[t++] = arr[right++];
            }
        } else if (type === 'desc') {  // 逆序排序
            if (arr[left] > arr[right]) {
                tempArr[t++] = arr[left++];
            } else {
                tempArr[t++] = arr[right++];
            }
        }
        
    }
    while (left <= mid) {
        tempArr[t++] = arr[left++];
    }
    while (right <= high) {
        tempArr[t++] = arr[right++];
    }
    while (t > 0) {
        arr[high--] = tempArr[--t];
    }
}

快速排序

快速排序是处理大数据集最快的排序算法之一。它是一种分而治之的算法,通过递归的方式将数据依次分解为包含较小元素和较大元素的不同子序列。该算法不断重复这个步骤直 到所有数据都是有序的。

这个算法首先要在列表中选择一个元素作为基准值(pivot)。数据排序围绕基准值进行, 将列表中小于基准值的元素移它的左边,将大于基准值的元素移到它的右边。

递归实现1

[完整代码地址]

/**
 * 快速排序 - 递归实现1
 * @param {array}  arr   待排序数组
 * @param {string} type  asc / desc
 */
function quick_recursive1 (arr, type) {
    // 记录数组长度
    var len = arr.length;
    // 若果数组长度为空
    if (len === 0) {
        return [];
    }
    var left = [],
        right = [];
    var pivot = arr[0];
    for (var i = 0; i < len; i++) {
        if (arr[i] < pivot) {
            if (!type || type === 'asc') { // 顺序排序
                left.push(arr[i]);
            } else if (type === 'desc') {
                right.push(arr[i]);        // 逆序排序
            }
        } else if (arr[i] > pivot) {
            if (!type || type === 'asc') { // 顺序排序
                right.push(arr[i]);
            } else if (type === 'desc') {
                left.push(arr[i]);        // 逆序排序
            }
        }
    }
    return quick_recursive1(left, type).concat(pivot, quick_recursive1(right, type));
}

这种方法很好理解,但是损耗了一定的性能,因为它的实现需要额外的存储空间。

递归实现2

[完整代码地址]

/**
 * 快速排序 - 递归实现2
 * @param {array}  arr   待排序数组
 * @param {string} type  asc / desc
 */
function quick_recursive2 (arr, type) {
    // 记录数组长度
    var len = arr.length;
    // 快速排序
    quick_sort (arr, 0, len - 1, type);
}

/**
 * 快速排序
 * @param {array}  arr  待排序数组
 * @param {number} low  排序最小下标
 * @param {number} high 排序最大下标
 * @param {string} type 排序类型
 */
function quick_sort (arr, low, high, type) {
    if (low < high) {
        var idx = partition(arr, low, high, type);
        quick_sort(arr, low, idx - 1, type);
        quick_sort(arr, idx + 1, high, type);
    }
}

/**
 * 将pivot放在适合的位置
 * @param {array}  arr  待排序数组
 * @param {number} low  排序最小下标
 * @param {number} high 排序最大下标
 * @param {string} type 排序类型
 */
function partition (arr, low, high, type) {
    var pivot = arr[low],
        tmpIdx = low;
    for (var i = low + 1; i <= high; i++) {
        if ((!type || type === 'asc') && arr[i] < pivot) { // 顺序排序
            tmpIdx++;
            swap(arr, tmpIdx, i);
        } else if (type === 'desc' && arr[i] > pivot) {    // 逆序排序
            tmpIdx++;
            swap(arr, tmpIdx, i);
        } 
    }
    swap(arr, low, tmpIdx);
    return tmpIdx;
}

/**
 * 交换数组里面的两个元素
 * @param {array} arr 待交换元素的数组
 * @param {number} i  待交换元素下标1
 * @param {number} j  待交换元素下标2
 */
function swap (arr, i, j) {
    var temp = arr[i];
    arr[i] = arr[j];
    arr[j] = temp;
}

这种递归方法,虽然较第一种复杂,但是它不需要额外的空间消耗。

迭代实现

[完整代码地址]

/**
 * 快速排序 - 迭代实现
 * @param {array}  arr   待排序数组
 * @param {string} type  asc / desc
 */
function quick_iteration (arr, type) {
    // 记录数组长度
    var len = arr.length;
    // 栈、栈顶指针
    var st = [], top = -1;
    // 入栈
    st[++top] = {
        low: 0,
        high: len
    };
    // 遍历
    while (top > -1) {
        // 取栈栈顶元素
        var el = st[top--];
        if (el.low < el.high) {
            var idx = partition(arr, el.low, el.high, type);
            st[++top] = { low: el.low, high: idx - 1 };
            st[++top] = { low: idx + 1, high: el.high };
        }
    }
}

/**
 * 将pivot放在适合的位置
 * @param {array}  arr  待排序数组
 * @param {number} low  排序最小下标
 * @param {number} high 排序最大下标
 * @param {string} type 排序类型
 */
function partition (arr, low, high, type) {
    var pivot = arr[low],
        tmpIdx = low;
    for (var i = low + 1; i <= high; i++) {
        if ((!type || type === 'asc') && arr[i] < pivot) { // 顺序排序
            tmpIdx++;
            swap(arr, tmpIdx, i);
        } else if (type === 'desc' && arr[i] > pivot) {    // 逆序排序
            tmpIdx++;
            swap(arr, tmpIdx, i);
        } 
    }
    swap(arr, low, tmpIdx);
    return tmpIdx;
}

/**
 * 交换数组里面的两个元素
 * @param {array} arr 待交换元素的数组
 * @param {number} i  待交换元素下标1
 * @param {number} j  待交换元素下标2
 */
function swap (arr, i, j) {
    var temp = arr[i];
    arr[i] = arr[j];
    arr[j] = temp;
}

这种实现方式只是将递归实现2递归部分,换成栈的实现,核心**都一样。

高级排序算法性能对比

本章最后,还是要测试一下上述三种高级排序算法的性能对比,在数量量级较大的时候,是否较其他基本排序算法,会表现得更好。值得注意的是,鉴于JavaScript在大数量级的情况下使用递归方式,很容易导致调用栈溢出,下面测试的高级排序算法,统一都是使用迭代实现的版本。

1000数量级(3次)运行结果

// 第一次
希尔排序  0  毫秒
归并排序  0  毫秒
快速排序  0  毫秒
// 第二次
希尔排序  0  毫秒
归并排序  1  毫秒
快速排序  0  毫秒
// 第三次
希尔排序  2  毫秒
归并排序  0  毫秒
快速排序  0  毫秒

可以看出,三种高级排序算法在1000数量级下,几乎不损耗时间。

10000数量级(3次)运行结果

// 第一次
希尔排序  10  毫秒
归并排序  2  毫秒
快速排序  2  毫秒
// 第二次
希尔排序  11  毫秒
归并排序  2 毫秒
快速排序  1  毫秒
// 第三次
希尔排序  10  毫秒
归并排序  3  毫秒
快速排序  2  毫秒

100000数量级(3次)运行结果

// 第一次
希尔排序  929  毫秒
归并排序  20  毫秒
快速排序  18  毫秒
// 第二次
希尔排序  940  毫秒
归并排序  21 毫秒
快速排序  20  毫秒
// 第三次
希尔排序  915  毫秒
归并排序  21  毫秒
快速排序  23  毫秒

我想,不用我多说,高级排序算法在数量级巨大的情况下,性能表现是非常优异的。(更大的数量级测试,有兴趣的同学可以自行测试)

参考链接

如何去实现call、apply、bind函数?

背景

相信,很多同学都有过和我同样的经历,在编写一个React组件的时候,常常要为某个监听事件的回调函数,绑定当前组件的上下文,形式大概如下:

import React, { Component } from 'react';

class MyComponent extends Component {
    constructor (props) {
        super(props);
        this.handleClick = this.handleClick.bind(this);
    }
    handleClick (e) {
         // dosomething...
    }
    render () {
        return (<button className="btn"
                        onClick={this.handleClick}>点我</button>);
    }
}

这里我们不讨论为什么React中的绑定事件的回调函数需要手动去绑定组件的上下文,有兴趣的同学可以自行搜索,或者点击这里。bind这个函数有什么大的魅力,能让我们在React开发中这么频繁地使用,背后到底做了什么?JavaScript类似bind的函数还有call和apply,那么它们又是如何去工作的,下面分享一下我自己的探索。

bind函数的实现

概念

bind()方法创建一个新的函数,在调用时设置this关键字为提供的值。并在调用新函数时,将给定参数列表作为原函数的参数序列的前若干项。(摘自MDN)

好吧,MDN似乎解释得有点云里雾里的。用我的话形容就是bind方法能够改变函数的this指向,并返回一个新的函数。值得注意的是,bind方法是作用于函数的,在接收的参数列表中,默认将第一个参数作为this绑定的对象,之后的一序列参数将会在传递的实参前传入作为它的参数。

那么,自己要实现一个bind函数,首先要知道,bind函数是怎么用的。

示例

let foo = bar.bind(context, ...args);

给当初和我一样好奇的同学:你们看到很多示例代码中用到的foo、bar、baz等标识符,就好像学校里面老师给我们举例子中的张山、李四、王五,没有特别的意思,只是一种约定成俗的东西。

说明

  • context是指要绑定的上下文。
  • args是需要传递的参数。
  • bar是需要绑定context上下文的函数。
  • foo是存储bar绑定context上下文后返回的函数。
  • bind函数定义是在Function.prototype中,不太熟悉JavaScript原型链的同学,请点击这里

注意

这里的bind函数调用返回的是一个绑定context上下文的函数引用,这是区别于call和apply调用后返回函数运行后的返回值。那么,如果我们不传context,那么context默认是指向谁呢?

let foo = function () {
    console.log(this);
}
let bindFoo = foo.bind();

// chrome、firefox、ie
bindFoo();  // window
// node
bindFoo();  // global

可见,我们什么也不传给bind函数的时候,默认上下文是指向全局对象的。

实现

Function.prototype.bind = function (context) {
     // 判断bind方法是否作用在函数上
    if (typeof this !== 'function') {
        // 抛出异常
        throw new Error("bind方法只作用于函数对象");
    }
     // 检测传入要绑定的上下文
    let _context = context || (typeof window === 'undefined' ? global : window);
     // 保存当前的this上下文,此时this是指调用bind方法的函数,这里作为闭包,供后面调用
    let _this = this;
     // 保存参数,除了第一个参数,因为第一次参数要作为绑定的上下文
    let args = [...arguments].slice(1);
    // 返回新的函数
    return function F () {
        // 因为返回了一个函数,我们可以 new F(),所以需要判断
        if (this instanceof F) {
            return new _this(...args, ...arguments)
        }
        // 为函数绑定新的上下文
        return _this.apply(_context, args.concat(...arguments))
    }
}

解析

  • 首先,从上面的代码可以看出,这个bind函数的定义是挂载在Function这个对象的原型链上的,利于重用。
  • 函数体里面第一句代码就是判断this这个引用是否是函数类型,例如foo.bind,条件判断中的this就是指foo这个引用。
  • _context是存储要绑定新的上下文。
  • _this是指向上面提到的foo,也就是指向调用bind方法的函数。
  • args是指处理第一个参数以后的所有参数,以数组形式存在。
  • 返回结果是一个新的函数,形成闭包,之前存储的变量,都可以在这个返回的新函数里面引用。
  • 这里返回的函数为什么不是匿名的呢?是因为后面有一处语句要判断,调用bind方法的函数是否是该返回函数的实例。也就是说,bind方法既然是返回一个新的函数,那么,我们可以把它当成构造函数使用,例如:
function Foo() {
    // pass
}
// 实例化
let instance = new Foo.bind(context, ...args);
// 等价于
let instance = new Foo(...args);
  • 有的同学就会疑惑了,需要绑定的context去哪里了呢?这个就涉及到各个方式绑定上下文的优先级了。new方法实例化绑定上下文的优先级最高,大于call和apply方法,还有bind方法。
  • 返回的新函数,最后还是返回了最初的函数引用调用了apply方法,其实我们可以看出,bind方法特性也称为函数的柯里化

call函数的实现

概念

call和apply都是为了解决改变 this 的指向,也就是改变函数的上下文,只是传参方式不一样。无论调用call还是apply方法,函数都会被立即执行。

示例

let context = {
    bar: 1
}

function foo (str) {
    console.log(this.bar);
    console.log(str);
}

foo.call(context, '你好,世界!');

输出

1
你好,世界!

说明

  • context也是指需要绑定的上下文,是call方法参数列表中的第一个,其后面所有参数都将作为实参传给调用call方法的函数,例如上面例子中的foo函数。
  • call方法的调用,会立即执行调用bind方法的函数。

思路

既然,call方法能改变函数的this指向,那么我们可以让需要绑定的上下文,可以执行这个函数即可。

实现

Function.prototype.call = function (context) {
    // 获取要绑定的上下文
    let _context = context || (typeof window === 'undefined' ? global : window);
    // 保存旧的fn属性
    let oldFn = _context.fn;
    // 给_context添加这个函数
    _context.fn = this;
    // 将 context 后面的参数截取出来
    let args = [...arguments].slice(1);
    // 调用函数,并保存返回结果
    let result = context.fn(...args)
    // 删除fn属性
    delete context.fn
    // 若旧的fn存在,则还原
    oldFn && (context.fn = oldFn);
    // 返回结果
    return result;
}

解析

这段代码理解起来并不难,但是需要注意的是,要先检测context是否已经存在了fn,若是,我们要先缓存,等call方法调用完后,我们再还原回去,不然,执行完我们自己定义的call方法后,若原来context对象原先已经存在了fn属性的话,则会被我们delete掉。

apply函数的实现

这里就不展开对apply方法的赘述了,它和call函数的区别就在于传参形式是数组。

实现

Function.prototype.apply= function (context) {
    // 获取要绑定的上下文
    let _context = context || (typeof window === 'undefined' ? global : window);
    // 保存旧的fn属性
    let oldFn = _context.fn;
    // 给_context添加这个函数
    _context.fn = this;
    // 获取参数
    let args = arguments[1] || [];
    // 调用函数
    let result = _context.fn(...args);
    // 删除fn属性
    delete context.fn;
    // 若旧的fn存在,则还原
    oldFn && (context.fn = oldFn);
    // 返回结果
    return result;
}

到此为止,我们已经实现了call、apply、bind函数的基本功能了,希望能帮助大家更好地理解这三者的原理和用法。

ES6 基础概述

1. let 和 const 命令

在ES6之前,JavaScript 声明变量只能用 var 这个关键字;而在ES6中,则引入了其他两个关键字 letconst,那么,它们俩,到底与 var 有哪些异同呢?请看下表:

名称 作用 是否存在变量提升 是否存在块级作用域 是否能重复声明
var 变量声明
let 变量声明 不能
const 常量声明 不能

2. 变量的解构赋值

ES6 允许按照一定模式,从数组和对象中提取值,对变量进行赋值,这被称为解构(Destructuring)。

数据类型 常见解构形式 描述
数组 let [a, b, c] = [1, 2, 3]; 按照对应位置,对变量赋值
对象 let { foo, bar } = { foo: 'aaa', bar: 'bbb' }; 变量必须与属性同名
字符串 const [a, b, c, d, e] = 'hello'; 字符串被转成类数组对象
数值 let {toString: s} = 123; 解构前转为对象
布尔值 let {toString: s} = true; 解构前转为对象
函数参数 let add = ([x, y]) => x + y; 类似数组解构
函数参数 let sub = ({x, y}) => x - y; 类似对象解构

3. 字符串扩展

3.1 模板字符串

模板字符串(template string)是增强版的字符串,用反引号(`)标识。

// es5
var name = 'Checkson';
var str = 'Hello ' + name + '!';

// es6
const name = 'Checkson';
const str = `Hello ${Checkson}!`;

3.2 includes() 实例方法

返回布尔值,表示是否找到了参数字符串(接受第二参数,代表开始搜索下标)。

// es5
var str = 'This is just test!';
if (str.indexOf('is') > -1) {
   // do something...
}

// es6
const str = 'This is just test!';
if (str.includes('is')) {
   // do something...
}

3.3 startsWith(), endsWith() 实例方法

两者都返回布尔值,前者表示参数字符串是否在原字符串的头部,后者表示参数字符串是否在原字符串的尾部(都接受第二参数,代表开始搜索下标)。

// es5
var str = 'Learn Once, Write Anywhere';
if (/^Learn/.test(str) || /Anywhere$/.test(str)) {
    // do something...
}

// es6
const str = 'Learn Once, Write Anywhere';
if (str.startsWith('Learn') || str.endsWith('Anywhere')) {
    // do something...
}

3.4 repeat() 实例方法

repeat方法返回一个新字符串,表示将原字符串重复n次,n为非负整数。

// es5
var str = '';
for (var i = 0; i < 6; i++) {
    str += 'template';
}

// es6
var str = 'template'.repeat(6);

3.5 padStart(), padEnd() 实例方法

ES2017 新增了 padStart()padEnd() 实例方法用来分别为字符串首、尾补全指定的字符。

// padStart
// es5
function prevZero (num) {
    return ('0' + num).substr(-2);
}

// es6
function prevZero (num) {
    return ('' + num).padStart(2, '0');
}

// padEnd 同理

3.6 trimStart(), trimEnd() 实例方法

ES2019 新增了 trimStart()trimEnd() 实例方法。它们的行为与trim()一致,前者消除字符串头部的空格,后者消除尾部的空格。

// trimStart
// es5
function trimStartSpace (str) {
    var res = '', isFirst = true ;
    for (var i = 0, len = str.length; i < len; i++) {
        if (isFirst && str[i] === ' ') {
            continue;
        }
        isFirst && (isFirst = !isFirst);
        res += str[i];
    }
    return res;
}

// es6
function trimStartSpace (str) {
    return str.trimStart();
}

// trimEnd 同理

4. 正则的扩展

字符串对象共有 4 个方法,可以使用正则表达式:match()replace()search()split()。ES6 将这 4 个方法,在语言内部全部调用RegExp的实例方法,从而做到所有与正则相关的方法,全都定义在RegExp对象上。

5. 数值的扩展

5.1 二进制和八进制表示法

ES6 提供了二进制和八进制数值的新的写法,分别用前缀0b(或0B)和0o(或0O)表示。

// 二进制
0b1000 === 8 // true
// 八进制
0o10 === 8   // true
// 转成十进制
Number(0b1000); // 8
Number(0o10);   // 8

5.2 Number.isFinite(), Number.isNaN()

ES6在Number对象上,新提供了Number.isFinite()Number.isNaN()两个方法。前者检查传入值是否有限,后者检查传入值是否为NaN。

// es5
isFinite(25) // true
isFinite("25") // true
isFinite(true) // true
// es6
Number.isFinite(25) // true
Number.isFinite("25") // false
Number.isFinite(true) // false

// es5
isNaN(NaN) // true
isNaN("NaN") // true
// es6
Number.isNaN(NaN) // true
Number.isNaN("NaN") // false
Number.isNaN(1) // false

它们与传统的全局方法isFinite()isNaN()的区别在于,传统方法先调用Number()将非数值的值转为数值,再进行判断,而这两个新方法只对数值有效,Number.isFinite()对于非数值一律返回false, Number.isNaN()只有对于NaN才返回true,非NaN一律返回false

5.3 Number.parseInt(), Number.parseFloat()

ES6 将全局方法parseInt()和parseFloat(),移植到Number对象上面,行为完全保持不变。这样做的目的,是逐步减少全局性方法,使得语言逐步模块化。

Number.parseInt === parseInt // true
Number.parseFloat === parseFloat // true

5.4 指数运算符

ES2016 新增了一个指数运算符(**)。

// es5
Math.pow(2, 2); // 4
Math.pow(2, 3); // 8

// es6
2 ** 2 // 4
2 ** 3 // 8

6. 函数的扩展

6.1 函数参数的默认值

ES6 之前,不能直接为函数的参数指定默认值,只能采用变通的方法。

// es5
function foo (x, y) {
    y = y || 'World';
    return x + y;
}

// es6
function foo (x, y = 'World') {
    return x + y;
}

6.2 解构 + 函数参数默认值

// es5
function foo (obj) {
    obj.y = obj.y || 2;
    return obj.x + obj.y;
}

// es6
function foo ({x, y = 2}) {
    return x + y;
}

6.3 rest 参数

ES6 引入 rest 参数(形式为...变量名),用于获取函数的多余参数,这样就不需要使用arguments对象了。

// es5
function add () {
    var args = [].slice.call(arguments);
    var sum = 0;
    args.forEach(function (val) {
         sum += val;
    });
    return sum;
}

// es6
function add(...args) {
  let sum = 0;
  for (let val of args) {
    sum += val;
  }
  return sum;
}

6.4 箭头函数

ES6 允许使用“箭头”(=>)定义函数。

// es5
var foo = function () {
    return false;
}

// es6
const foo = () => false;

注意: 对于箭头函数,函数体内的this对象,就是定义时所在的对象,而不是使用时所在的对象;不可以当作构造函数(不能被new);不可以使用arguments对象;不可以使用yield命令。

6.5 尾递归

函数调用自身,称为递归。如果尾调用自身,就称为尾递归。

// 非尾递归 - 容易栈溢出
function factorial (n) {
  if (n === 1) return 1;
  return n * factorial(n - 1);
}

// 尾递归
function factorial (n, total) {
    if (n === 1) return total;
    return factorial(n - 1, total * n);
}

7. 数组的扩展

7.1 Array.from()

Array.from方法用于将两类对象转为真正的数组:类似数组的对象(array-like object)和可遍历(iterable)的对象(包括 ES6 新增的数据结构 Set 和 Map)。

let arrayLike = {
    '0': 'a',
    '1': 'b',
    '2': 'c',
    length: 3
};

// es5
var arr1 = [].slice.call(arrayLike); // ['a', 'b', 'c']

// es6
let arr2 = Array.from(arrayLike); // ['a', 'b', 'c']

7.2 Array.of()

Array.of方法用于将一组值,转换为数组。

//  es5
var arr = [];
[].push.call(arr, 1, 2, 3);

// es6
const arr = Array.of(1, 2, 3);

7.3 find() 实例方法

数组实例的find方法,用于找出第一个符合条件的数组成员。若果找到符合条件的成员,则返回第一个满足条件的成员;如果找不到满足条件的成员,返回undefined

var arr = [1, 2, 3, 4],  res = 'undefine';

// es5
for (var i = 0; i < arr.length; i++) {
      if (arr[i] > 2) {
           res = arr[i]; // 3
           break;
      }
}

// es6
res = arr.find(item => item > 2); // 3

7.4 findIndex() 实例方法

findIndex 方法与 indexOf 方法作用类似,但是findIndex能识别数组是否存在NaN

// es5
[1, NaN, 2, NaN].indexOf(NaN); // -1

// es6
[1, NaN, 2, NaN].findIndex(item => isNaN(item)); // 1

7.5 fill() 实例方法

fill方法使用给定值,填充一个数组。

// es5
new Array(6, 6, 6); // [6, 6, 6]

// es6
new Array(3).fill(6); // [6, 6, 6]

7.6 includes() 实例方法

ES2016引入Array.prototype.includes方法返回一个布尔值,表示某个数组是否包含给定的值,与字符串的includes方法类似。

// es5
[1, 2, 3].indexOf(2) > -1 // true 

// es6
[1, 2, 3].includes(2); // true
[1, 2, NaN].includes(NaN); // true

7.7 flat 实例方法

数组的成员有时还是数组,Array.prototype.flat()用于将嵌套的数组“拉平”,变成一维的数组。该方法返回一个新数组,对原数据没有影响。

// es5
function flatten(arr) {
    return arr.reduce((a, b) => {
        return a.concat(Array.isArray(b) ? flatten(b) : b);
    }, []);
};
flatten([1, 2, [3, 4]]); // [1, 2, 3, 4]

// es6
[1, 2, [3, 4]].flat(); // [1, 2, 3, 4]

8. 对象的扩展

8.1 属性的简洁表示法

ES6 允许直接写入变量和函数,作为对象的属性和方法。

// es5
function foo (x, y) {
    return { x: x, y: x };
}

// es6
function foo (x, y) {
    return {x, y};
}

8.2 属性名表达式

ES6 允许字面量定义对象时,表达式作为对象的属性名,即把表达式放在方括号内。

// es5
var obj = {
     foo: 1
};
obj['b' + 'ar'] = 2;

// es6
var obj = {
    foo: 1,
    ['b' + 'ar']: 2
}

8.3 属性遍历方法

ES6 一共有 5 种方法可以遍历对象的属性。

(1)for...in

for...in循环遍历对象自身的和继承的可枚举属性(不含 Symbol 属性)。

(2)Object.keys(obj)

Object.keys返回一个数组,包括对象自身的(不含继承的)所有可枚举属性(不含 Symbol 属性)的键名。

(3)Object.getOwnPropertyNames(obj)

Object.getOwnPropertyNames返回一个数组,包含对象自身的所有属性(不含 Symbol 属性,但是包括不可枚举属性)的键名。

(4)Object.getOwnPropertySymbols(obj)

Object.getOwnPropertySymbols返回一个数组,包含对象自身的所有 Symbol 属性的键名。

(5)Reflect.ownKeys(obj)

Reflect.ownKeys返回一个数组,包含对象自身的所有键名,不管键名是 Symbol 或字符串,也不管是否可枚举。

8.4 扩展运算符

对象的扩展运算符(...)用于取出参数对象的所有可遍历属性,拷贝到当前对象之中。

var foo = { a: 1 };
var bar = { b: 2, c: 3 };

// es5
var baz = {};
for (var k in foo) baz[k] = foo[k];
for (var k in bar) baz[k] = bar[k];

// es6
var baz = {
    ...foo,
    ...bar
}

8.5 Object.is()

Object.is()方法用来判断两值是否一样

// es5
-0 === +0 // true
NaN === NaN // false

// es6
Object.is(-0, +0); // false
Object.is(NaN, NaN); // true;

8.6 Object.assign()

Object.assign方法用于对象的合并,将源对象(source)的所有可枚举属性,复制到目标对象(target)。

var foo = { a: 1 };
var bar = { b: 2 };
var baz = { c: 3 };

// es5 - 对象合并
for (var prop in bar) foo[prop] = bar[prop];
for (var prop in baz) foo[prop] = baz[prop];
console.log(foo);  // { a: 1, b: 2, c: 3 }

// es6 - 对象合并
Object.assign(foo, bar, baz); 
console.log(foo); // { a: 1, b: 2, c: 3};

8.7 Object.values()

Object.values方法返回一个数组,成员是参数对象自身的(不含继承的)所有可遍历(enumerable)属性的键值。

var obj = { foo: 'bar', baz: 42 };
// es5
var arr = [];
for (var prop in obj) arr.push(obj[prop]); // ['bar', 42]

// es6
Object.values(obj); // ['bar', 42]

8.8 Object. entries()

Object.entries()方法返回一个数组,成员是参数对象自身的(不含继承的)所有可遍历(enumerable)属性的键值对数组。

var obj = { foo: 'bar', baz: 42 };
// es5
var arr = [];
for (var prop in obj) arr.push([prop, obj[prop]]); // [["foo", "bar"], ["baz", 42]]

// es6
Object.entries(obj); // [["foo", "bar"], ["baz", 42]]

9. Symbol

ES6 引入了一种新的原始数据类型Symbol,表示独一无二的值,可以保证不会与其他属性名产生冲突。

var x = Symbol();
var y = Symbol();

x === y // false
typeof x; // "Symbol"

9.1 可以为Symbol添加描述

const sym = Symbol('foo');
sym.description // foo

9.2 作为属性名的 Symbol

let mySymbol = Symbol();

// 第一种写法
let a = {};
a[mySymbol] = 'Hello!';

// 第二种写法
let a = {
  [mySymbol]: 'Hello!'
};

// 第三种写法
let a = {};
Object.defineProperty(a, mySymbol, { value: 'Hello!' });

9.3 Symbol.for

该方法可以重用同一个 Symbol 值。

const s1 = Symbol.for('foo');
const s2 = Symbol.for('foo');

s1 === s2 // true

9.4 Symbol.keyFor

该方法返回一个已登记的 Symbol 类型值的key

let s1 = Symbol.for("foo");
Symbol.keyFor(s1) // "foo"

10. Set 和 Map 数据解构

10.1 Set 定义

ES6 提供了新的数据结构 Set (集合)。它类似于数组,但是成员的值都是唯一的,没有重复的值。

Set 本身是一个构造函数,它可以接受一个数组(或者具有 iterable 接口的其他数据结构)作为参数,用来初始化。下面罗列几种常见的创建形式:

// 方式一
const set = new Set();
[2, 3, 3, 4, 5, 5, 6].forEach(x => set.add(x));
console.log(set); // Set(5) {2, 3, 4, 5, 6}

// 方式二
const set = new Set([2, 3, 3, 4, 5, 5, 6]);
console.log(set); // Set(5) {2, 3, 4, 5, 6}

// 方式三
const set = new Set(document.querySelectorAll('div'));

10.2 Set 实例的属性和方法

  • Set 实例的属性:
    • Set.prototype.constructor: 构造函数,默认是就是 Set 函数。
    • Set.prototype.size: 返回 Set 实例的成员总数。
  • Set 实例的方法:
    • add(value):添加某个值,返回 Set 结构本身。
    • delete(value): 删除某个值,返回一个布尔值,表示删除是否成功。
    • has(value): 返回一个布尔值,表示该值是否为 Set 的成员。
    • clear():清除所有成员,没有返回值。
var set = new Set();

set.add(1).add(2).add(2);

set.size // 2

set.has(1); // true
set.has(2); // true
set.has('2'); // false
set.has(3); // false

set.delete(2); // true
set.has(2); // false

set.clear();
set.size: // 0

10.3 Set 遍历

Set 结构的实例有四个遍历方法,可以用于遍历成员。

  • keys():返回键名的遍历器
  • values():返回键值的遍历器
  • entries():返回键值对的遍历器
  • forEach():使用回调函数遍历每个成员
let set = new Set(['red', 'green', 'blue']);

for (let item of set.keys()) {
  console.log(item);
}
// red
// green
// blue

for (let item of set.values()) {
  console.log(item);
}
// red
// green
// blue

for (let item of set.entries()) {
  console.log(item);
}
// ["red", "red"]
// ["green", "green"]
// ["blue", "blue"]

10.4 Set 应用

  • 数组去重
[...new Set([1, 1, 2, 3, 3, 4, 4, 4, 5])]; // [1, 2, 3, 4, 5]
  • 字符串去重
[...new Set('abababcd')].join(''); // abcd
  • 实现并集(Union)、交集(Intersect)和差集(Difference)
let a = new Set([1, 2, 3]);
let b = new Set([2, 3, 4]);

// 并集
let union = new Set([...a, ...b]);
// Set {1, 2, 3}

// 交集
let intersect = new Set([...a].filter(x => b.has(x)));
// Set {2, 3}

// 差集
let difference = new Set([...a].filter(x => !b.has(x)));
// Set {1}

10.5 Map 定义

JavaScript 的对象(Object),本质上是键值对的集合(Hash 结构),但是传统上只能用字符串当作键。这给它的使用带来了很大的限制。

var a = { foo: 1 }, b = {};
b[a] = 1;

console.log(b); // {[object Object]: 1} 自动将对象转化为字符串

为了解决这个问题,ES6 提供了 Map 数据结构。它类似于对象,也是键值对的集合,但是“键”的范围不限于字符串,各种类型的值(包括对象)都可以当作键。

const m = new Map();
const o = {p: 'Hello World'};

m.set(o, 'content')
m.get(o) // "content"

m.has(o) // true
m.delete(o) // true
m.has(o) // false

Map作为构造函数,也可以接受一个数组作为参数。该数组的成员是一个个表示键值对的数组。

const map = new Map([
  ['name', '张三'],
  ['title', 'Author']
]);

map.size // 2
map.has('name') // true
map.get('name') // "张三"
map.has('title') // true
map.get('title') // "Author"

10.6 Map 实例属性和方法

  • Map 属性:
    • Map.prototype.constructor: 构造函数,默认是就是 Map 函数。
    • Map.prototype.size: 返回 Map 实例的成员总数。
  • Map 操作方法:
    • set(key, value) 方法设置键名key对应的键值为value,然后返回整个 Map 结构。
    • get(key) 方法读取 key 对应的键值,如果找不到 key,返回 undefined
    • has(key) 方法返回一个布尔值,表示某个键是否在当前 Map 对象之中。
    • delete(key) 方法删除某个键,返回 true。如果删除失败,返回 false
    • clear() 方法清除所有成员。
const map = new Map();

map.set('name', 'checkson');
map.get('name'); // checkson
map.set('age', 23).set('sex', 'male'); // 链式用法

map.has('name');
map.size; // 3

map.delete('age');
map.size; // 2

map.clear();
map.size; // 0

10.7 Map遍历

Map 结构原生提供三个遍历器生成函数和一个遍历方法。

  • keys():返回键名的遍历器。
  • values():返回键值的遍历器。
  • entries():返回所有成员的遍历器。
  • forEach():遍历 Map 的所有成员。

需要特别注意的是,Map 的遍历顺序就是插入顺序。

const map = new Map([
  ['F', 'no'],
  ['T',  'yes'],
]);

for (let key of map.keys()) {
  console.log(key);
}
// "F"
// "T"

for (let value of map.values()) {
  console.log(value);
}
// "no"
// "yes"

for (let item of map.entries()) {
  console.log(item[0], item[1]);
}
// "F" "no"
// "T" "yes"

// 或者
for (let [key, value] of map.entries()) {
  console.log(key, value);
}
// "F" "no"
// "T" "yes"

// 等同于使用map.entries()
for (let [key, value] of map) {
  console.log(key, value);
}
// "F" "no"
// "T" "yes"

参考图书

《ECMAScript 6 入门》

LeetCode(力扣)答案解析(九)

61. 旋转链表

给定一个链表,旋转链表,将链表每个节点向右移动 k 个位置,其中 k 是非负数。

示例1:

输入: 1->2->3->4->5->NULL, k = 2
输出: 4->5->1->2->3->NULL
解释:
向右旋转 1 : 5->1->2->3->4->NULL
向右旋转 2 : 4->5->1->2->3->NULL
示例2

示例2:

输入: 0->1->2->NULL, k = 4
输出: 2->0->1->NULL
解释:
向右旋转 1 : 2->0->1->NULL
向右旋转 2 : 1->2->0->NULL
向右旋转 3 : 0->1->2->NULL
向右旋转 4 : 2->0->1->NULL

题解:

/**
 * Definition for singly-linked list.
 * function ListNode(val) {
 *     this.val = val;
 *     this.next = null;
 * }
 */
/**
 * @param {ListNode} head
 * @param {number} k
 * @return {ListNode}
 */
var rotateRight = function(head, k) {
    if (!k) return head;
    var p = head,
        trail = head;
    var order = 0,
        len = 0;
    while (p) {
        trail = p;
        len++;
         p = p.next;
    }
    k = k % len;
    p = head;
    while (order < len - k) {
        order++;
        if (order == len - k) {
            trail.next = head;
            head = p.next;
            p.next = null;
            break;
        }
        p = p.next;
    }
    return head;
};

解析:

这道题的思路也很简单:

  • 先找出链表中的最后一个节点的位置,并记录链表长度 len
  • 然后用 k 对链表长度 len 取余,减少无用的循环次数(当 k >= len,就存在白走了至少一轮链表移动)。
  • 最后遍历到第 len - k 个元素(这里借助了 len - k - 1 元素),将其置为头节点,并且将链表原来的尾节点 trailnext 指针,指向链表头部 head 即可。

11. 盛最多水的容器

给定 n 个非负整数 a1,a2,...,an,每个数代表坐标中的一个点 (i, ai) 。在坐标内画 n 条垂直线,垂直线 i 的两个端点分别为 (i, ai) 和 (i, 0)。找出其中的两条线,使得它们与 x 轴共同构成的容器可以容纳最多的水。

说明: 你不能倾斜容器,且 n 的值至少为 2。

示例:

输入: [1,8,6,2,5,4,8,3,7]
输出: 49

题解:

/**
 * @param {number[]} height
 * @return {number}
 */
var maxArea = function(height) {
    var front = 0,
        rear = height.length - 1,
        max = 0;
    while (front < rear) {
        max = Math.max(Math.min(height[front], height[rear]) * (rear - front), max)
        if (height[front] <= height[rear]) {
            front++;
        } else {
            rear--;
        }
    }
    return max;
};

解析:

略。

238. 除自身以外数组的乘积

给定长度为 n 的整数数组 nums,其中 n > 1,返回输出数组 output ,其中 output[i] 等于 nums 中除 nums[i] 之外其余各元素的乘积。

示例:

输入: [1,2,3,4]
输出: [24,12,8,6]

说明: 请不要使用除法,且在 O(n) 时间复杂度内完成此题。

进阶:
你可以在常数空间复杂度内完成这个题目吗?( 出于对空间复杂度分析的目的,输出数组不被视为额外空间。)

题解

/**
 * @param {number[]} nums
 * @return {number[]}
 */
var productExceptSelf = function(nums) {
    var res = [1];
    for (var i = 1, len = nums.length; i < len; i++) {
        res[i] = res[i - 1] * nums[i - 1]; // [1, 1, 2, 6]
    }
    var right = 1;
    for (var i = nums.length - 1; i >= 0; i--) {
        res[i] *= right;
        right *= nums[i];
    }
    return res; // [24, 12, 8, 6]
};

解析:

这个解法非常巧妙,首先 res 数组是记录每个数左边的乘积,然后乘以每个数右边的乘积,就得到了答案。

JavaScript 二叉树

背景

是计算机科学中经常用到的一种数据结构。树是一种非线性的数据结构,以分层的方式存储数据。树被用来存储具有层级关系的数据,比如文件系统中的文件;树还被用来存储有序列表。这里将研究一种特殊的树:二叉树。选择树而不是那些基本的数据结构,是因在二叉树上进行查找非常快(而在链表上查找则不是这样),为二叉树添加或删除元素也非常快(而对数组执行添加或删除操作则不是这样)。

定义

在认识 二叉树 之前,我们先看看 的定义。

树由一组以边连接的节点组成。而我们日常使用的文件系统(以Mac OS为例),也是一种树状结构,如下图所示:

Mac OS文件系统

在图中,每一个方框就是一个节点,连接方框的线叫做边。节点代表了该文件系统中的各个文件,边描述了各个文件之间的关系。

另外,树最顶端的节点叫做 根节点,如果一个节点连接了多个节点,那么我们称他为 父节点,它下面的节点称为 子节点。一个节点可以拥有0个或者多个节点,没有任何子节点的节点我们称为 叶子节点

二叉树 是一种特殊的树,其原因是它的子节点不能超过两个。二叉树具有特殊的计算性质,使得在它们之上的一些操作异常高效。下面将详细讨论。

二叉树和二叉查找树

二叉树 不允许其节点超过连个子节点。通过控制树中每一个子节点的个数限定为2,可以写出高效的程序在树中插入、查找、删除数据。

二叉树中的一个父节点的两个子节点,分别称为 左节点右节点。在一些特定的二叉树中,左节点包含一组特定的值,右节点也包括一组特定的值。下面展示了一颗二叉树:

二叉树

我们这里只讨论一种特殊的二叉树:二叉查找树,也叫 二叉搜索树。二叉查找树是一种特殊的二叉树,相对较小的值保存在左节点中,相对较大的值保存在右节点中。基于这个特性,使得二叉查找树的查找效率很高,对于数值型和非数值类型的数据,比如文本单词或者字符,都是如此。

二叉查找树(BST)类的实现

完整代码地址。

二叉查找树由树节点组成。我们先要定义一个Node对象。

function Node (data, left, right) {
    this.data = data;
    this.left = left;
    this.right = right;
}

Node对象既保存数据,也保存其子节点(左节点和右节点)的引用(指针)。

接下来,我们构建BST类。

完整代码地址。

1. 构造函数

function BST () {
    this.root = null;
}

2. 插入节点操作

BST.prototype.insert = function (data) {
    var node = new Node(data, null, null);
    if (!this.root) {
        this.root = node;
        return;
    }
    var current = this.root,
        parent;
    do {
        parent = current;
        if (data < current.data) {
            current = current.left;
            if (!current) {
                parent.left = node;
                break;
            }
        } else {
            current = current.right;
            if (!current) {
                parent.right = node;
                break;
            }
        }
    } while (true);
}

3. 中序遍历

BST.prototype.inOrder = function (node) {
    if (node) {
        this.inOrder(node.left);
        console.log(node.data);
        this.inOrder(node.right);
    }
}

4. 先序遍历

BST.prototype.preOrder = function (node) {
    if (node) {
        console.log(node.data);
        this.preOrder(node.left);
        this.preOrder(node.right);
    }
}

5. 后序遍历

BST.prototype.postOrder = function (node) {
    if (node) {
        this.postOrder(node.left);
        this.postOrder(node.right);
        console.log(node.data);
    }
}

6. 获取最小值

BST.prototype.getMin = function () {
    if (!this.root) {
        return null;
    }
    var current = this.root;
    while (current.left) {
        current = current.left;
    }
    return current.data;
}

7. 获取最大值

BST.prototype.getMax = function () {
    if (!this.root) {
        return null;
    }
    var current = this.root;
    while (current.right) {
        current.right;
    }
    return current.data;
}

8. 查找二叉查找树中给定的值

BST.prototype.find = function (data) {
    var current = this.root;
    while (current) {
        if (current.data === data) {
            return current;
        } else if (current.data > data) {
            current = current.left;
        } else {
            current = current.right;
        }
    }
    return null;
}

9. 在二叉查找树上删除节点

  • 首先,判断当前是否包含待删除的数据,如果包含,删除该节点;如果不包含,则比较当前节点上的数据和待删除的数据。如果待删除数据小于当前 节点上的数据,则移至当前节点的左子节点继续比较;如果删除数据大于当前节点上的数 据,则移至当前节点的右子节点继续比较。
  • 如果待删除节点是叶子节点(没有子节点的节点),那么只需要将从父节点指向它的链接 指向 null。
  • 如果待删除节点只包含一个子节点,那么原本指向它的节点久得做些调整,使其指向它的 子节点。
  • 如果待删除节点包含两个子节点,正确的做法有两种:要么查找待删除节点左子树 上的最大值,要么查找其右子树上的最小值。这里我们选择后一种方式。
BST.prototype.remove = function (data) {
    this.removeNode(this.root, data);
}

BST.prototype.removeNode = function (node, data) {
    if (!node) {
        return null;
    }
    if (data === node.data) {
        if (!node.left && !node.right) {
            return null;
        } else if (!node.left) {
            return node.right;
        } else if (!node.right) {
            return node.left;
        } else {
            var tempNode = this.getSmallestNode(node.right);
            node.data = tempNode.data;
            node.right = this.removeNode(node.right, tempNode.data);
        }
    } else if (data < node.data) {
        node.left = this.removeNode(node.left, data);
    } else {
        node.right = this.removeNode(node.right, data);
    }
    return node;
}

// 获取给定子树最小值的节点
BST.prototype.getSmallestNode = function (node) {
    if (!node) {
        return null;
    }
    while (node.left) {
        node = node.left;
    }
    return node;
}

参考链接

平衡二叉树,AVL树之图解篇
3 分钟理解完全二叉树、平衡二叉树、二叉查找树
平衡二叉查找树(AVL)的查找、插入、删除

JavaScript 散列

前言

我曾经以为,字典和散列表这两者是没什么区别的,在 JavaScript 中最具有代表的数据结构是 Object 类了。事实上,并不是这样的。在字典中,我们用 键-值 的形式来存储数据。在散列表中也是一样(也是以 键-值对的形式来存储数据)。但是两种数据结构的实现方式略有不同。

前一章,我们聊到字典在 JavaScript 可以笼统理解为 Object 类,这一章我们重点介绍散列表,及其与字典有什么本质的区别。

定义

散列是一种常用的数据存储技术,散列后的数据可以快速地插入或取用。散列使用的数据结构叫做散列表。在散列表上插入、删除和取用数据都非常快,但是对于查找操作来说却效率低下,比如查找一组数据中的最大值和最小值。这些操作得求助于其他数据结构,二叉查找树就是一个很好的选择。本章将介绍如何实现散列表,并且了解什么时候应该用散列表存取数据。

概览

我们的散列表是基于数组进行设计的。数组的长度是预先设定的,如有需要,可以随时增加。所有元素根据和该元素对应的键,保存在数组的特定位置,该键和我们前面讲到的字典中的键是类似的概念。使用散列表存储数据时,通过一个散列函数将键映射为一个数字,这个数字的范围是 0 到散列表的长度。

理想情况下,散列函数会将每个键值映射为一个唯一的数组索引。然而,键的数量是无限的,数组的长度是有限的(理论上,在 JavaScript 中是这样),一个更现实的目标是让散列函数尽量将键均匀地映射到数组中。

即使使用一个高效的散列函数,仍然存在将两个键映射成同一个值的可能,这种现象称为碰撞(collision),当碰撞发生时,我们需要有方案去解决。

要确定的最后一个问题是:散列表中的数组究竟应该有多大?这是编写散列函数时必须要考虑的。对数组大小常见的限制是:数组长度应该是一个质数。在实现各种散列函数时,我们将讨论为什么要求数组长度为质数。之后,会有多种确定数组大小的策略,所有的策略都基于处理碰撞的技术。因此,我们将在讨论如何处理碰撞时对它们进行讨论。如下图,以一些字符串散列为例,阐释了散列的概念。

散列表

散列表(HashTable)类实现

完整代码地址,我们使用一个类来表示散列表,该类包含计算散列值的方法、向散列中插入数据的方法、从散列表中读取数据的方法、显示散列表中数据分布的方法,以及其他一些可能会用到的工具方法。

1. 构造函数

function HashTable () {
    this.table = new Array(137);
}

2. simpleHash:简单散列函数

散列函数的选择依赖于键值的数据类型。如果键是整型,最简单的散列函数就是以数组的长度对键取余。在一些情况下,比如数组的长度是 10,而键值都是 10 的倍数时,就不推荐使用这种方式了。这也是数组的长度为什么要是质数的原因之一,就像我们在上个构造函数中,设定数组长度为 137 一样。如果键是随机的整数,则散列函数应该更均匀地分布这些键。这种散列方式称为除留余数法

在很多应用中,键是字符串类型。事实证明,选择针对字符串类型的散列函数是很难的,散列选择时必须加倍小心。

回过头来看,将字符串中每个字符的 ASCII 码值相加似乎是一个不错的散列函数。这样散列值就是 ASCII 码值的和除以数组长度的余数。该散列函数的定义如下:

HashTable.prototype.simpleHash = function (data) {
    var total = 0;
    for (var i = 0, len = data.length; i < len; i++) {
        total += data.charCodeAt(i);
    }
    return total % this.table.length;
}

3. betterHash:更好的哈希函数

为了避免碰撞,首先要确保散列表中用来存储数据的数组其大小是个质数。这一点很关键,这和计算散列值时使用的取余运算有关。数组的长度应该在 100 以上,这是为了让数据在散列表中分布得更加均匀。通过试验我们发现,比 100 大且不会让大部分数据产生碰撞的第一个质数是 137。使用其他更接近 100 的质数,在该数据集上依然会产生碰撞。

为了避免碰撞,在给散列表一个合适的大小后,接下来要有一个计算散列值的更好方法。霍纳算法很好地解决了这个问题。在此算法中,新的散列函数仍然先计算字符串中各字符的 ASCII 码值,不过求和时每次要乘以一个质数。

大多数算法书建议使用一个较小的质数,比如 31,但是对于我们的数据集,31 不起作用,我们使用 37,这样刚好不会产生碰撞。

现在我们有了一个使用霍纳算法的更好的散列函数:

HashTable.prototype.betterHash = function (data) {
    const H = 37;
    var total = 0;
    for (var i = 0, len = data.length; i < len; i++) {
        total += H * total + data.charCodeAt(i);
    }
    total = total % this.table.length;
    return parseInt(total);
}

4. put:向哈希表中添加元素

HashTable.prototype.put = function (key, data) {
    var pos = this.betterHash(key);
    this.table[pos] = data;
}

5. get:根据key获取哈希表中的值

HashTable.prototype.get = function (key) {
    return this.table[this.betterHash(key)];
}

6. showDistro:显示散列表元素信息

HashTable.prototype.showDistro = function () {
    for (var i = 0, len = this.table.length; i < len; i++) {
        if (this.table[i] != undefined) {
            console.log(i + ": " + this.table[i]);
        }
    }
}

哈希表(HashTable)类测试

var hashTable = new HashTable();
hashTable.put('张三', '13910241024');
hashTable.put('李四', '13520482048');
hashTable.put('王五', '13440964096');
hashTable.showDistro();
console.log('----------------------------');
console.log('王五的电话号码:', hashTable.get('王五'));

运行结果:

31: 13440964096
53: 13910241024
94: 13520482048
----------------------------
王五的电话号码: 13440964096

碰撞问题

当散列函数对于多个输入产生同样的输出时,就产生了碰撞。这里将介绍如何解决碰撞,使所有的键都得以存储在散列表中。我们下面将讨论两种碰撞解决办法:开链法线性探测法

1. 开链法

开链法 就是改成 HashTable 中的数组改成二维数组,当发现两个值经过散列函数计算后相同,就将两个值,两两按 键-值 形式存入一维数组。例如现在HashTable中的数组每个元素都成了一维数组了,而这个一维数组第一个元素存键,第二个元素存对应的值,两两按顺序组合。

2. 线性探测法

线性探测法,故名思意,就是线性处理碰撞问题。在 HashTable 存入元素时,发现当前 key 值存在元素了,就寻找该元素后一个位置,直到找到空位置为止,然后存储起来。

参考链接

动态插入脚本和样式

动态脚本

总所周知,使用<script>元素可以向页面中插入JavaScript代码,具体有两种方式:

  • 通过src特性包含外部文件 (插入外部文件)
  • 用这个元素本身包含代码 (直接插入JavaScript代码)

定义

动态脚本,一般指的是在页面加载时不存在,但将来的某一时刻通过修改DOM动态添加的脚本。

1. 加载外部JavaScript文件

入门版

function loadScript (url) {
    var script = document.createElement('script');
    script.type = 'text/javascript';
    script.src = url;
    document.body.appendChild(script);
}

有人就会问,那我怎么监听这个动态脚本是否加载完成了?不急,还有进阶版嘛

进阶版

function loadScript (url, callback) {
    var script = document.createElement('script')
    script.type = 'text/javascript';
    if (script.readyState) {    // 针对IE
        script.onreadystatechange = function () {
            if (script.readyState == 'loaded' || script.readyState == 'complete') {
                script.onreadystatechange = null;
                callback();
            }
        }
    } else {                    // 针对其他浏览器
        script.onload = function () {
            callback();
        }
    }
    script.src = url;
    document.body.appendChild(script);
}

简单说明一下,script.readyState在除IE下的浏览器输出都是 undefined 的,而 loaded 是代表数据加载完成,complete 是代表数据已经准备好了。这两个状态在不同的IE下表现又不一致,时而出现前者又不出现后者,时而出现后者又不出现前者,所以上面写法最保险了。

2. 直接在script标签插入JavaScript代码

入门版

function loadStriptString (code) {
    var script = document.createElement('script');
    script.type = 'text/javascript';
    script.appendChild(document.createTextNode(code));
    document.body.appendChild(script);
}

上述代码在高级版本的浏览器中能正常运行,但在IE会报错。IE将<script>视为一个特殊的元素,不允许DOM访问其子节点。不过,可以使用<script>元素的text属性来制定JavaScript代码。请看进阶版

进阶版

function loadStriptString (code) {
    var script = document.createElement('script');
    script.type = 'text/javascript';
    try {
        script.appendChild(document.createTextNode(code));
    } catch (e) {
        script.text = code;
    }
    document.body.appendChild(script);
}

特意说明一下,以上代码引用了3次 document,进行了3次全局变量查找,我们可以在函数内第一句代码前加入 var doc = document;,以后引用 doc 变量来替代 document 就可以了,减少全局变量查找。(请读者自行修改)

动态样式

类似动态脚本,能够把CSS样式动态包含到HTML页面中的元素的方法有两种:

  • 使用link元素用于包含来自外部的文件
  • 使用<style>元素用于指定嵌入样式

1. 使用link包含外部文件

常用版

function loadStyles (url) {
    var link = document.createElement('link');
    link.rel = 'stylesheet';
    link.type = 'text/css';
    link.href = url;
    var head = document.head || document.getElementsByTagName('head')[0];
    head.appendChild(link);
}

需要注意的是,必须将<link>元素添加到<head>元素上而不是<body>元素上,才能保证在所有浏览器中的行为一致。

2. 使用<style>元素包含嵌入的CSS

常用版

function loadStyleString (css) {
    var style = document.createElement('style');
    style.type = 'text/css';
    try {
        style.appendChild(document.createTextNode(css));
    } catch (e) {
        style.styleSheet.cssText = css;
    }
    var head = document.head || document.getElementsByTagName('head')[0];
    head.appendChild(style);
}

这里也运用了try catch语句,为了兼容IE浏览器。

ES6 遍历器

前言

ES5中原有的“集合”数据结构,主要是对象(object)和数组(array)。其实,数组也是特殊的对象。而ES6添加了两种集合类型:MapSet。为了方便用户遍历这些集合,ES6引入了遍历器(Iterator)机制。

概念

遍历器(Iterator)是一种机制。它是一种接口,为各种不同的数据结构提供统一的访问机制。任何数据结构只要部署 Iterator 接口,就可以完成遍历操作。

作用

  • 为各种数据结构,提供一个统一的、简便的访问接口。
  • 使得数据结构的成员能够按某种次序排列。
  • ES6 创造了一种新的遍历命令for...of循环,Iterator 接口主要供for...of消费。

遍历过程

(1)创建一个指针对象,指向当前数据结构的起始位置。也就是说,遍历器对象本质上,就是一个指针对象。
(2)第一次调用指针对象的next方法,可以将指针指向数据结构的第一个成员。
(3)第二次调用指针对象的next方法,指针就指向数据结构的第二个成员。
(4)不断调用指针对象的next方法,直到它指向数据结构的结束位置。

每一次调用next方法,都会返回数据结构的当前成员的信息。具体来说,就是返回一个包含value和done两个属性的对象。其中,value属性是当前成员的值,done属性是一个布尔值,表示遍历是否结束。

下面是一个模拟next方法返回值的例子。

function iteratorFactory (array) {
    var nextIndex = 0;
    return {
        next: function () {
            return nextIndex < array.length ?
                   { value: array[nextIndex++], done: false } :
                   { value: undefined, done: true }
        }
    }
}

var iterator = iteratorFactory(['a', 'b']);

iterator.next(); // { value: 'a', done: false }
iterator.next(); // { value: 'b', done: false }
iterator.next(); // { value: undefine, done: true }

上面代码定义了一个 iteratorFactory 函数,它是一个遍历器生成函数,作用就是返回一个遍历器对象。对数组 ['a', 'b'] 执行这个函数,就会返回该数组的遍历器对象(即指针对象)iterator

指针对象的next方法,用来移动指针。开始时,指针指向数组的开始位置。然后,每次调用next方法,指针就会指向数组的下一个成员。第一次调用,指向a;第二次调用,指向b

next方法返回一个对象,表示当前数据成员的信息。这个对象具有valuedone两个属性,value属性返回当前位置的成员,done属性是一个布尔值,表示遍历是否结束,即是否还有必要再一次调用next方法。

由于 Iterator 只是把接口规格加到数据结构之上,所以,遍历器与它所遍历的那个数据结构,实际上是分开的,完全可以写出没有对应数据结构的遍历器对象,或者说用遍历器对象模拟出数据结构。下面是一个无限运行的遍历器对象的例子。

function idFactory () {
  var index = 0;

  return {
    next: function() {
      return {value: index++, done: false};
    }
  };
}

var it = idFactory();

it.next().value // 0
it.next().value // 1
it.next().value // 2
// ...

上面的例子中,遍历器生成函数 idFactory,返回一个遍历器对象(即指针对象)。但是并没有对应的数据结构,或者说,遍历器对象自己描述了一个数据结构出来。

特别注意:理解以上的示例代码非常重要,那是遍历器的最基本概念。

默认 Iterator 接口

ES6 规定,默认的 Iterator 接口部署在数据结构的 Symbol.iterator 属性,或者说,一个数据结构只要具有 Symbol.iterator 属性,就可以认为是“可遍历的”(iterable)。Symbol.iterator 属性本身是一个函数,就是当前数据结构默认的遍历器生成函数。执行这个函数,就会返回一个遍历器。至于属性名Symbol.iterator,它是一个表达式,返回Symbol对象的iterator属性,这是一个预定义好的、类型为 Symbol 的特殊值,所以要放在方括号。

const objIterater = {
    [Symbol.iterator] : function () {
        var id = 0;
        return {
            next: function () {
                return id > 3 ? { value: undefined, done: true } : 
                                 { value: id++, done: false };
            }
        };
    }
};

for (let item of objIterater) {
    console.log(item);
}

// 输出结果
// 0
// 1
// 2
// 3

上面代码中,对象obj是可遍历的(iterable),因为具有 Symbol.iterator 属性。执行这个属性,会返回一个遍历器对象。该对象的根本特征就是具有next方法。每次调用next方法,都会返回一个代表当前成员的信息对象,具有valuedone两个属性。这里我们用 for...of 命令就能遍历出相关的数据。

ES6 的有些数据结构原生具备 Iterator 接口(比如数组),即不用任何处理,就可以被 for...of 循环遍历。原因在于,这些数据结构原生部署了 Symbol.iterator 属性(详见下文),另外一些数据结构没有(比如对象)。凡是部署了 Symbol.iterator 属性的数据结构,就称为部署了遍历器接口。调用这个接口,就会返回一个遍历器对象。

原生具备 Iterator 接口的数据结构如下。

  • Array
  • Map
  • Set
  • String
  • TypedArray
  • 函数的 arguments 对象
  • NodeList 对象

下面的例子是数组的 Symbol.iterator 属性。

let arr = ['a', 'b', 'c'];
let iter = arr[Symbol.iterator]();

iter.next() // { value: 'a', done: false }
iter.next() // { value: 'b', done: false }
iter.next() // { value: 'c', done: false }
iter.next() // { value: undefined, done: true }

上面代码中,变量arr是一个数组,原生就具有遍历器接口,部署在arr的 Symbol.iterator 属性上面。所以,调用这个属性,就得到遍历器对象。

对于原生部署 Iterator 接口的数据结构,不用自己写遍历器生成函数,for...of 循环会自动遍历它们。除此之外,其他数据结构(主要是对象)的 Iterator 接口,都需要自己在 Symbol.iterator 属性上面部署,这样才会被 for...of 循环遍历。

对象(Object)之所以没有默认部署 Iterator 接口,是因为对象的哪个属性先遍历,哪个属性后遍历是不确定的,需要开发者手动指定。本质上,遍历器是一种线性处理,对于任何非线性的数据结构,部署遍历器接口,就等于部署一种线性转换。不过,严格地说,对象部署遍历器接口并不是很必要,因为这时对象实际上被当作 Map 结构使用,ES5 没有 Map 结构,而 ES6 原生提供了。

参考链接

基于Node.js异步优先队列2.0来袭

前言

阔别Web前端3年多时间里面,今天我重新抽空整理了一下之前的一个开源库:priority-async-queue,一个基于Node.js的异步优先任务队列。不了解1.0版本的同学可以戳这里,我们今天主要围绕着paq的设计初衷和适用场景展开来说。

相比1.0,2.0改进了什么?

改进的主要有三点:

  • 1.0只支持单并发状态执行任务队列,2.0则加入了支持多并发执行状态,具体并发数,用户可以自行设置。
  • 2.0为每个任务加入了执行时间的统计,具体包括任务创建时间creatTime,任务开始执行时间startTime,和任务执行结束时间endTime。用户可以自己根据需求计算所需的任务等待时间:startTime - createTime,或者任务执行消耗的时间endTime - startTime,又或者只是用来写入日志文件,记录执行时间相关信息。
  • 1.0只支持在Node.js环境下执行,2.0则提供支持浏览器可执行的输出文件。让浏览器端遇到同样困扰的同学,能够迎刃而解。

paq设计思路

paq设计思路其实非常简单,一共就4个类组成:

  • Task 是描述每个待执行(异步/同步)任务的执行逻辑以及配置参数。
  • PriorityQueue 是控制每个待执行(异步/同步)任务的优先级队列、具有队列的基本属性和作。
  • AsyncQueue 是控制每个待执行(异步/同步)任务能严格一定顺序地执行的队列。
  • Event 模拟事件监听和事件触发的类。

2.0为了兼容浏览器环境能正常运行,去掉了对Node.js原生事件类EventEmitter的依赖,自己实现了简易的事件绑定和触发的功能。

下面是paq 2.0的程序流程图:

paq2 0

paq的设计初衷

我刚转岗到游戏开发的时候,部门迫切需要一个集群打包系统来处理庞大的打包业务。当时,我临危受命,接下了这个任务。后来,我开发的集群打包系统,其打包采用的任务调度,最核心底层代码架构近似于paq。当然,实际应用会比paq复杂很多,因为游戏打包流程是一个极其复杂而繁琐的过程,我只是抽离了最核心通用的调度思路来开源成一个通用库。

试想,如果一台打包机只能在同一时间执行单个打包任务,那么就太浪费硬件资源了。但受限于CPU核数、硬盘空间、内存容量、数据读写速度等因素,我们又不能粗暴地向打包机里加入并发执行的打包任务,所以这个时候,能控制好每台打包机的并发数显得尤为重要。既要保证效率,也要保证安全可靠。

paq适用场景

首先,我们必须明确的是,在绝大部分业务场景里面,你可能不需要paq。没设置并发数的paq,其任务默认是严格按照顺序执行,并发数始终维持为1,这在绝大部份情况已经降低执行效率。JavaScript原生支持的异步任务和事件循环,本来就是要充分发挥在单线程执行环境下,最大限度利用CPU多核的特性,从而提高程序执行效率。

可能不少同学都已经知道,在浏览器端,最大并发请求数,每个浏览器厂商都做了一定的限制,如:Chrome允许的最大并发请求数目为6,FireFox是4,每个浏览器版本之间又会存在一定的差异。总而言之,主流浏览器在网络请求方面已经帮我们做好了负载均衡的工作了。而在Node.js环境下,负载均衡问题则需要我们开发者自己来解决。

如果,在短时间内,一大批客户端产生大量的网络请求时候,服务器的承受能力肯定是有限的。这个时候,需要我们用一个像队列的数据结构容器来先存好这些请求,然后按照先进先出的原则来慢慢提供给服务端处理,压力会减少很多。说到这里,很多有服务端经验的同学,第一时间就会想到消息队列。没错,paq很像消息队列,但它没有遵守生产者消费者模式。所以paq不能单独处理分布式和集群业务的调度,它更适合放在MQ的下游。

paq特点

1.paq更小、更易用。

paq有效源码大概200行左右,Node.js环境下是非常精小的。但在浏览器端,打包压缩后的paq也有18KB,主要来自是ES6语法兼容性代码的冗余。

做前端开发者,无论是Web、移动端原生和游戏开发,最折磨的莫过于要兼容各种用户终端运行的环境和设备。

下面是paq最基础的用法,开箱即用:

const PAQ = require('priority-async-queue');
const paq = new PAQ();

paq.addTask(() => {
  console.log('Helo World!');
});

// Hello World!

接着,我们来看看字节的一道经典面试题。

class Scheduler {
  add(promiseCreator) {
    // 完善Scheduler,使其并发数为2
  }
}

const timeout = (time) => new Promise(resolve => {
  setTimeout(resolve, time);
})

const scheduler = new Scheduler();

const addTask = (time, order) => {
  scheduler.add(() => timeout(time)).then(() => console.log(order));
}

addTask(1000, 1);
addTask(500, 2);
addTask(300, 3);
addTask(400, 4);

// 要求输出顺序
// 2
// 3
// 1
// 4

大家可以稍加思考一下,怎么扩展 Scheduler 类能完成需求。如果见过或者已经知道怎么做的同学不妨看看用paq怎么轻松实现这个需求。

const PAQ = require('priority-async-queue');
// 实例化paq时,使其并发数为2
const paq = new PAQ(2);

class Scheduler {
  add(promiseCreator) {
    return new Promise(resolve => {
      paq.addTask({
        completed: (ctx, res) => {
          resolve(res);
        }
      }, () => promiseCreator());
    });
  }
}

...

至于,不借助paq又怎么实现这个需求呢?有兴趣的同学,可以在评论区分享自己的实现方式。

2.paq更贴合Node.js开发习惯

const PAQ = require('priority-async-queue');
const paq = new PAQ();

// 链式调用结构
paq.addTask(() => {
  console.log('one');
}).addTask(() => {
  console.log('two');
}).addTask(() => {
  console.log('three');
});

// one
// two
// three

// 支持原生async和promise等异步操作
paq.addTask(() => {
  return new Promise(resolve => {
    paq.sleep(1000).then(() => {
      console.log('sleep 1s');
      resolve();
    });
  });
});
paq.addTask(async () => {
  await paq.sleep(1000).then(() => {
    console.log('sleep 1s too');
  });
});

// sleep 1s
// sleep 1s too

3.使用灵活

只要paq设置的并发数足够大,或者和处理业务峰值相当,那么它就能近似Promise.all那样无限制并发执行,但是paq不会等所有任务都完成后才进行下一步操作。

const PAQ = require('priority-async-queue');
// 并发上限设置足够大
const paq = new PAQ(20);

const p1 = () => paq.sleep(1000).then(() => Promise.resolve('p1'));
const p2 = () => paq.sleep(1000).then(() => Promise.resolve('p2'));
const p3 = () => paq.sleep(1000).then(() => Promise.resolve('p3'));

paq.addTask(p1).addTask(p2).addTask(p3).on('completed', (opt, result) => {
  console.log(result);
});

Promise.all([p1(), p2(), p3()]).then(res => {
  console.log(res);
});

// p1
// p2
// p3
// [ 'p1', 'p2', 'p3' ]

如果paq只处理第一个返回状态的任务,则它的用法接近Promise.race的用法了。

const PAQ = require('priority-async-queue');
// 并发上限设置足够大
const paq = new PAQ(100);

const p1 = () => paq.sleep(3000).then(() => Promise.resolve('p1'));
const p2 = () => paq.sleep(2000).then(() => Promise.resolve('p2'));
const p3 = () => paq.sleep(1000).then(() => Promise.resolve('p3'));

let isFirst = false;
paq.addTask(p1).addTask(p2).addTask(p3).on('completed', (opt, result) => {
  if (!isFirst) {
    // TODO 只处理第一个改变状态的任务
    console.log('paq: ' + result);
    isFirst = true;
  }
});

Promise.race([p1(), p2(), p3()]).then(res => {
  console.log('race: ' + res);
});

// paq: p3
// race: p3

paq近似Promise.allSettledPromise.any的用法我就不再展开了。个人认为,日常开发中能用原生实现的,尽量用原生实现,本文只是起介绍作用,不构成使用建议。

学海无涯,剑圣说过:“真正的大师永远都怀着一颗学徒的心”,所以被人称作:“易大师”。我们这些普通人如果坚持学习,虽然最后可能成不了大师,但起码不会摆烂吧?

函数柯里化为何物?

定义

函数柯里化(Currying)是把接受多个参数的函数变成接受一个单一参数(最初的第一个参数)的函数,并且返回接受余下的参数且返回结果的新函数的技术。

函数柯里化并不是JavaScript特有的。用笼统的话形容则是:减少函数参数个数的传递,并返回一个新的函数,这个新的函数能够处理旧函数剩下的参数。

简单示例:

// 计算两个数相加并返回计算结果的函数,接受两个参数a和b
function add (a, b) {
    return a + b;
}
// 将函数柯里化
function curry (a) {
    return function (b) {
        return a + b;
    }
}
// 应用函数柯里化
var _add = curry(1);
// 输出结果
console.log(_add(2)); // 3
// 比较结果
console.log(_add(2) === add(1, 2)); // true
// 另一种比较
console.log(curry(1)(2) === add(1, 2)); // true

这个是比较简单的函数柯里化过程,细心的同学会发现,示例中的函数封装(柯里化)方式是具有较大的局限性的,不过它能给大家对函数柯里化有一种大概的认识。

以上简单的示例可能并不能说什么,接下来,我们将给出更详细的例子去配合理解。

详细示例:

假设有一个接受三个参数且求三个数之和的add函数

function add (a, b, c) {
    // do something...
}

然后经过我们的柯里化(curry)函数封装后得到_add函数

var _add = curry(add);

那么_addcurry封装后返回的柯里化函数,根据上述的定义,它能够处理add的剩余参数。因此下面的函数调用都是等价的。

add(a, b, c) <=> _add(a, b, c) <=> _add(a, b)(c) <=> _add(a)(b,c) <=> _add(a)(b)(c)

所以说,柯里化也叫做"部分求值"。

实现

我们将上面简单示例中的curry函数,改成更加通用形式:

function curry(fn) {
    // 记录原函数参数个数
    var len= fn.length;
    // 记录传参个数
    var args = [].slice.call(arguments, 1);
    
    // 返回新的函数
    return function() {
        // 保存新接收的参数为数组
        var _args = [].slice.call(arguments);
        // 将新旧两参数数组合并
        [].unshift.apply(_args, args);
        
        // 如果累积接收的参数个数少于原函数接受的参数,则递归调用
        if (_args.length < len) {
            return curry.call(this, fn, ..._args);
        }
        // 若个数满足要求,则返回原函数调用结果
        return fn.apply(this, _args);
    }
}

示例应用:

function add (a, b, c) {
    console.log(a + b + c)
    return a + b + c;
}
var _add = curry(add);
_add(1, 2, 3);  // 6
_add(1)(2, 3);  // 6
_add(1, 2)(3);  // 6
_add(1)(2)(3);  // 6
var _add = curry(add, 1);
_add(2, 3);     // 6
_add(2)(3);     // 6
var _add = curry(add, 1, 2);
_add(3);        // 6
var _add = curry(add, 1, 2, 3);
_add();         // 6

这里代码逻辑也不难。我们只需判断参数个数是否达到函数柯里化前的个数,若没有,则递归调用柯里化函数;若达到了,则执行函数,并返回执行后的结果。

有的同学就苦恼了,函数柯里化,其实都最后还不是函数执行自身吗,为什么还搞那么多花里胡哨的*操作呢?函数柯里化确实把问题复杂化了,但同时提高了函数调用的自由度,这正是函数柯里化的核心所在。

请看一个常见的例子。

假设我们有一个需求,需要验证用户输入是否是正确的手机号码,那么大家可能会这样封装函数:

function checkPhone (phoneNumber) {
    return /^1[34578]\d{9}$/.test(phoneNumber);
}

又假设我们还有一个需求需要验证邮箱正确性,我们可能又有如下封装:

function checkEmail(email) {
    return /^(\w)+(\.\w+)*@(\w)+((\.\w+)+)$/.test(email);
}

这时候,产品经理又过来问我们,能不能加上验证身份证号码、登陆密码之类的。因此,我们为了保持通用性,常常会有这样的封装:

function check (reg, str) {
    return reg.test(str);
}

这时,我们就会有这样子的调用形式:

check(/^1[34578]\d{9}$/, '12345678910');
check(/^(\w)+(\.\w+)*@(\w)+((\.\w+)+)$/, '[email protected]');
...

如果要按照这种封装形式,我们要调用多次验证的话,需要多次传入相同的正则匹配,而正则匹配往往是固定不变的。那么这个时候,我们可以通过函数柯里化来让这些函数调用,变得优雅一些:

var _check = curry(check);

var checkPhone = _check(/^1[34578]\d{9}$/);
var checkEmail = _check(/^(\w)+(\.\w+)*@(\w)+((\.\w+)+)$/);

最后的函数调用就会变得简洁明了了:

checkPhone('13912345678');
checkEmail('[email protected]');

我们可以发现,函数柯里化能够应对较复杂多变的业务需求,学好它是前端进阶的重点。

高级应用

前端有一道关于柯里化的面试题,广为流传。

题目: 实现一个add方法,使以下等式成立

add(1)(2)(3) = 6
add(1, 2)(3)(4, 5) = 15
add(1, 2, 3)(4, 5)(6) = 21
add(1, 2) = 3

这里需要补充一个重要的知识点:函数的隐式转换。当我们直接将函数参与其他运算的时候,函数会默认调用toString方法:

function fn () { return 1; }
console.log(fn + 1); // 输出结果为:function fn () { return 1; }1

我们可以重写函数的toString方法。

function fn() { return 1; }
fn.toString = function() { return 2; }
console.log(fn + 1); // 3

此外我们还可以重写函数的valueOf方法,得到同样的效果:

function fn() { return 1; }
fn.valueOf = function() { return 3; }

console.log(fn + 1); // 4

当同时重写函数的toStringvalueOf方法时,以valueOf为准。

function fn() { return 1; }
fn.toString = function() { return 2; }
fn.valueOf = function() { return 3; }

console.log(fn + 1); // 4

补充这个重要的知识点后,那么咱们直接撸代码了:

function add () {
  // 存储所有参数
  var args = [].slice.call(arguments);

  function adder () {
     // 保存参数 
    args.push(...arguments);
    // 重写valueOf方法
    adder.valueOf = function () {
      return args.reduce((a, b) => a + b);
    }
    // 递归返回adder函数
    return adder;
  }

  // 返回adder函数调用
  return adder();

}

代码校验:

console.log(add(1)(2)(3) == 6)             // true
console.log(add(1, 2)(3)(4, 5) == 15)      // true
console.log(add(1, 2, 3)(4, 5)(6) == 21)   // true
console.log(add(1, 2) == 3)                // true

这里代码的核心**就是利用闭包来保存传入的所有参数和函数隐式转换。

基于Node.js的优先异步队列

前言

想不到我在日常开发中,竟然遇到“优先异步队列”的需求。github上有类似功能,并且集成redis的库有KueBull等。但是,对于追求“简、易、轻”的我来说,其实并不满意。根据“二八原则”,我决定,自己来实现一个只有上述两者“两成”功能的轻量级开源库:priority-async-queue

开源库的命名为什么这么长呢?原因是,我认为开发者只需看一眼库名就知道其功能,一点都不含糊。但是,为了行文流畅,priority-async-queue下面统一简称为“paq”。

你可能不需要paq

按照 redux 作者的套路,首先,我们需要明确的是,你可能不需要 paq

只有遇到 N 个异步任务不能并行执行,并且只能顺序执行时,你才需要使用 paq。

简单来说,如果你需要执行的 N 个异步任务,并不存在资源争夺和占用、数据共享、严格的逻辑顺序、优先权对比等,paq 可能是没必要的,用了反而降低执行效率、影响程序性能。

paq 设计思路

paq 的设计思路非常简单,一共3个类:

  • Task 是描述每个待执行(异步/同步)任务的执行逻辑以及配置参数。
  • PriorityQueue 是控制每个待执行(异步/同步)任务的优先级队列、具有队列的基本属性和操作。
  • AsyncQueue 是控制每个待执行(异步/同步)任务能严格顺序地执行的队列。

下面是 paq 的程序流程图:

paq

paq 基本概念和API

1. addTask

addTask 是核心方法,它可以创建一个任务,并且添加到 paq 队列中。

paq.addTask([options, ]callback);

options 是一个可选对象,包含以下属性:

{
  id: undefined,          // 任务id
  priority: 'normal',     // 任务权重,例如: low, normal, mid, high, urgent
  context: null,          // 执行任务的上下文
  start: (ctx, options) => {}, // 任务将要被执行的回调
  completed: (ctx, res) => {}, // 任务执行完成后的回调
  failed: (ctx, err) => {},    // 任务执行失败后的回调
  remove: (ctx, options) => {} // 任务被删除后的回调
}

callback 是一个描述执行任务的逻辑的函数,它包含两个参数:ctxoptions

  • ctx 是任务所属的paq实例。
  • options 是此任务的options参数的最终值。
paq.addTask((ctx, options) => {
  console.log(ctx === paq); // true
});

2. removeTask

removeTask 方法是根据任务 id 来删除等待对列中的任务。

paq.removeTask(taskId);

如果成功删除任务,它将返回true。否则,它将返回false。

3. pause

pause 方法是暂停 paq 继续执行任务。

paq.pause();

注意: 但是,您无法暂停当前正在执行的任务,因为无法暂时检测到异步任务的进度。

4. isPause

isPause 属性,返回当前队列是否处于暂停状态。

paq.isPause; // return true or false.

5. resume

resume 方法,用来重新启动 paq 队列执行任务。

paq.resume();

6. clearTask

cleartTask 方法,用来清除 paq 等待队列中所有的任务。

paq.clearTask();

paq 用法

1. 基本用法

只要向 paq 添加任务,该任务就会自动被执行。

const PAQ = require('priority-async-queue');
const paq = new PAQ();

paq.addTask((ctx, options) => {
  console.log('This is a simple task!');
});

// This is a simple task!

2. 同步任务

你可以使用 paq 执行一系列同步任务,例如:

const syncTask = (n) => {
  for (let i = 0; i < n; i++) {
    paq.addTask(() => {
      console.log('Step', i, 'sync');
      return i;
    });
  }
};

syncTask(3);

// Step 0 sync
// Step 1 sync
// Step 2 sync

3. 异步任务

你还可以使用 paq 执行一系列的异步任务,例如:

const asyncTask = (n) => {
  for (let i = 0; i < n; i++) {
    paq.addTask(() => {
      return new Promise(resolve => {
        setTimeout(() => {
          console.log('Step', i, 'async');
          resolve(i);
        }, i * 1000);
      });
    });
  }
};

asyncTask(3);

// Step 0 async
// Step 1 async
// Step 2 async

4. 混合任务

你甚至可以使用 paq 执行一系列同步和异步交错的任务,例如:

const mixTask = (n) => {
  asyncTask(n);
  syncTask(n);
  asyncTask(n);
};

mixTask(2);

// Step 0 async
// Step 1 async
// Step 0 sync
// Step 1 sync
// Step 0 async
// Step 1 async

5. 绑定执行上下文

有时如果你需要指定上下文来执行任务,例如:

const contextTask = (n) => {
  var testObj = {
    name: 'foo',
    sayName: (name) => {
      console.log(name);
    }
  };
  for (let i = 0; i < n; i++) {
    paq.addTask({ context: testObj }, function () {
      this.sayName(this.name + i);
    });
  }
};

contextTask(3);

// foo0
// foo1
// foo2

注意: this 在箭头函数中并不存在,或者说它是指向其定义处的上下文。

6. 延迟执行

paq 还支持延迟执行任务,例如:

const delayTask = (n) => {
  for (let i = 0; i < n; i++) {
    paq.addTask({ delay: 1000 * i }, () => {
      console.log('Step', i, 'sync');
      return i;
    });
  }
};

delayTask(3);

// Step 0 sync
// Step 1 sync
// Step 2 sync

7. 优先权

如果需要执行的任务具有权重,例如:

const priorityTask = (n) => {
  for (let i = 0; i < n; i++) {
    paq.addTask({ priority: i === n - 1 ? 'high' : 'normal' }, () => {
      return new Promise(resolve => {
        setTimeout(() => {
          console.log('Step', i, 'async');
          resolve(i);
        }, i * 1000);
      });
    });
  }
};

priorityTask(5);

// Step 0 async
// Step 4 async
// Step 1 async
// Step 2 async
// Step 3 async

默认优先级映射如下:

{
  "low": 1,
  "normal": 0, // default
  "mid": -1,
  "high": -2,
  "urgent": -3
}

8. 回调函数

有时,你希望能够在任务的开始、完成、失败、被删除时做点事情,例如

const callbackTask = (n) => {
  for (let i = 0; i < n; i++) {
    paq.addTask({
      id: i,
      start: (ctx, options) => {
        console.log('start running task id is', options.id);
      },
      completed: (ctx, res) => {
        console.log('complete, result is', res);
      },
      failed: (ctx, err) => {
        console.log(err);
      }
    }, () => {
      if (i < n / 2) {
        throw new Error(i + ' is too small!');
      }
      return i;
    });
  }
};

callbackTask(5);

// start running task id is 0
// Error: 0 is too small!
// start running task id is 1
// Error: 1 is too small!
// start running task id is 2
// Error: 2 is too small!
// start running task id is 3
// complete, result is 3
// start running task id is 4
// complete, result is 4

9. 删除任务

有时,你需要删除一些任务,例如:

const removeTask = (n) => {
  for (let i = 0; i < n; i++) {
    paq.addTask({
      id: i,
      remove: (ctx, options) => {
        console.log('remove task id is', options.id);
      }
    }, () => {
      return new Promise(resolve => {
        setTimeout(() => {
          console.log('Step', i, 'async');
          resolve(i);
        }, i * 1000);
      });
    });
  }
  console.log(paq.removeTask(3));
  console.log(paq.removeTask(5));
};

removeTask(5);

// remove task id is 3
// true
// false
// Step 0 async
// Step 1 async
// Step 2 async
// Step 4 async

注意: 你必须在创建任务时分配id,并根据id删除任务。

paq 事件

如果需要监视 paq 队列的状态,paq 提供以下事件侦听器:

1. addTask

paq.on('addTask', (options) => {
  // Triggered when the queue adds a task.
});

2. startTask

paq.on('startTask', (options) => {
  // Triggered when a task in the queue is about to execute.
});

3. changeTask

paq.on('changeTask', (options) => {
  // Triggered when a task in the queue changes.
});

4. removeTask

paq.on('removeTask', (options) => {
  // Triggered when the queue remove a task.
});

5. completed

paq.on('completed', (options, result) => {
  // Triggered when the task execution in the queue is complete.
});

6. failed

paq.on('failed', (options, err) => {
  // Triggered when a task in the queue fails to execute.
});

最后,想补充的是:若遇到 Promise.allPromise.race 解决不了的需求,可以考虑一下 paq

Web前端退役篇

前言

不要尝试去寻找确定性,要学会拥抱变化,因为变化才是人生常态。

生涯回顾

你们可曾想过,自己当初为什么会踏入现在从事的行业?

我最初接触 Web 前端是在2016年7月初(大二下学期末),当时是参加学校组织的专业实训,实训内容大概是结合 Java 的三大马车(ssh框架)的学习成果,做一个电商系统。经过小组内的讨论,最终我是负责系统的前端页面工作,也是第一次接触一些网页前端开发的相关开发技术:jQueryH5。从那以后,我对 Web 前端的认识就有了一个雏形。

到了2016年7月底,当时,刷了一年算法水题的我,不再满足天天面对着枯燥无味的ACM题库,于是,在一个偶然的机会,加入了由我的计算机启蒙导师佳哥组建的OJ(Online Judge)团队,担任题库维护仔和 Web 前端打杂仔,开启了边刷题,边维护开发OJ系统前端之旅。在OJ团队里面,成员组成全都是同系同专业的师兄弟,而且技术氛围异常浓烈。我也是在这个时候,认识了真正意义上,带我入门 Web 前端的师兄豪哥。豪哥是OJ系统前端总负责人,是大我两届直系师兄,他带着比我大一届的直系师兄朋哥和萌新的我。当时的OJ系统运行着的前端框架是由谷歌研发的 AngularJS.1.4.x 版本,一个富有哲学**的框架。

一个刚学会几句 jQuery 语句的我,面对着两个能把 AngularJS 玩转的人,只能躲在角落里面瑟瑟发抖。当时OJ系统前端采用的构建工具叫Gulp,再配合一堆 Gulp 生态的插件,通过 Nodejs 将工程代码、图片、字体等资源打包压缩在 dist 目录下,整个流程一体化,这也是我第一次接触前端自动化构建相关的内容。在参与OJ系统的维护中,我初步认识了:MVVM双向数据绑定指令过滤器依赖注入路由DOM操作耗性能等概念,为后来进军现代 Web 前端打下了坚实的基础。

到了2016年10月中旬,一路 Carry 我的豪哥师兄早已按时毕业了,OJ项目此时急需前端接班人,而大我一级的朋哥师兄也忙于大四毕业实习,那么,自然也只有我这等闲人才有时间去技术维护项目,成为新一代的接班人。坦白地讲,我当时加入OJ团队的初衷,仅仅只是出于好奇心,和抱着求学心态,完全没有做项目前端负责人的准备。但是,大环境趋势就是要让我背负着这个重任,顺应历史潮流的我,从此踏上了专研 Web 前端的道路,告别了摸鱼的日子。

到了2016年年底,我的OJ团队导师,看我平时能力还行,又肯学习和吃苦,维护的OJ系统又还算稳定,于是,我有幸加入了他当时的创立的一个孵化项目,目标是要研发一个能配置出管理系统的系统(下文统称为:配置系统)。我当时,真是初生牛犊不怕虎啊,完全忽略其开发工作量之大和技术要求之高,以为单凭一己之力能 hold 住这个系统的所有 Web 前端功能。我相信,开发过表单设计器图表设计器报表设计器以及富文本编辑器的同学应该都知道,如果能把设计器里面的视图,分成若干个原子组件随意搭配组合成所需展示的页面,并且能动态绑定后台数据,正确地显示在页面上(所见即所得),对 Web 前端技术考验到底有多高!年少的我不知天高地厚,只凭满腔的热血,一股脑儿扎进入就是一顿乱干,年轻就是任性!

配置系统开发前期的技术储备真得艰辛,以前维护别人的项目最多算是从“1”到“1+”,但是当自己真正去尝试从“0”到“1”建立新的项目的时候,才意识到每个开创者才是最伟大的,所以,我觉得我们要充分尊重每个项目中的创建者,他们才是最不容易的人!造全新的轮子,远比重造轮子难得多得多。这也是为什么很多新人,即使在现有的成熟项目中表现出色,但是某一天让他/她亲自来挑大梁的时候,效果却不如人意。

在豪哥和导师的指导下,配置系统的前端技术栈选用的是当时很火 React,为什么是它?原因很简单,当时真正突出组件化**的前端框架,也只有 React 比较明显了。但在这里,我不得不吐槽一句,用 React 单纯写界面确实很简单,但是用它来写完整的系统就非常复杂了。请别杠,当时的我还是大三学生,而且 React 创建组件的方式还只是 React.CreateClass 的方式,最重要的是不同于现在的生态那么丰富。刚接触 React 时,我非常不习惯数据驱动Virtual DOM等理念,虽然受过 AngularJS 的熏陶,但很多时候差点没忍住引入 jQuery 来直接操作 DOM,幸好最后一直坚持遵循 React 的设计理念,才得以让该项目的前端技术栈更加干净和纯粹。

大约花了两三天的时间,我刚学会用 React 成功写出了需求中几个简单常用的组件,并正确地渲染到页面上,但却发现,要用 React 开发高性能的系统,不能只在页面引入一个 babel 插件链接来实时解析 React 代码,这样运行的 React 应用性能会大打折扣。于是我就在度娘中四处搜寻解决法案,最后发现一个叫 Webpack 的打包工具。说到这,我又忍不住吐槽起来了,Webpack1.x 的官方文档晦涩难懂,加上并不完善和明确的加载器(loader),如果当时有初学者能只看官方文档操作就成功配置出能跑的 React 项目算我输。我几乎全靠第三方技术博客分享,才得以搭建好整个项目的开发环境。

后来,好不容易,啃完 Webpack 这个硬骨头了,但随着开发进度往前的推进,发现 React 库因为过轻,没有内置的路由功能,我只好又去问“度娘”了。幸运的是,当时的 React-Router 并不算难找和复杂,不然我又免不了一翻折腾。

一顿操作下来,我本以为技术储备已经到了万事俱备之时。然而,事实上,我只是迈出了取经之路的第一步而已。当我开发遇到任意组件间互相需要通信的时候,才发现单纯的父组件作为子组件通信的桥梁并不能适用。于是,Redux 自然而然地受到了我的关注。Redux 涵盖的纯函数高阶组件单向数据流ActionState等概念,短时间内搞得我晕头转向。当时 Redux 刚出不久,我还要费好长时间去抉择用 Flux 还是 Redux,换作现在,简直可笑。但不得不说,对于绝大部分的初学者来说,状态管理这一块应该是最烧脑的了,我花了1周左右的时间,才真正弄懂并应用到实战中(在这里我要特别感谢阮一峰老师的博客教程)。不得不说,我发现一个很有趣的现象,在当时很多 Web 前端新的、优质的、前沿的知识和技术,很少作者能在官网把自己的想法和理念讲清楚,并且能够很好地传递给开发者的,往往都需要借助第三方技术博客文章来辅助理解,也或许因此,让那时候很多技术博客自媒体火起来了。不管怎么样,最后我自己还是坚持把 React 开发环境搭好了。现在回想,才深刻感受到脚手架是一个多么伟大的发明呀!

配置系统后续遇到的技术细节和难点我就不展开了。纵使我们夜以继日般地学习,披星戴月般地开发,最后配置系统完整的功能还是没有按预期那样完成。但我无法忘记当时奋斗的样子,每日面对无数个超出能力范围的问题,但却一次次地解决,大概也是我一步一步地让自己蜕变的过程。项目最后虽然失败了,但是我学会三样宝贵的东西:搜索能力学习能力解决问题的能力,这三种能力的培养在我日后职场生涯中起到至关重要的作用。

时间继续往前推进,很快到了2017年3月底,在导师的介绍下,我来到了一家专门为研究院服务的外包企业实习,真正开启了技术套现之路。碰巧的是,当时入职后负责带我的师兄,恰巧是当时和我一起做OJ项目的朋哥。朋哥是一个极具注重细节的人,在他的教化中,我养成了很多强迫症,几乎到了像素级细节都不放过。实习公司前端用的技术栈是当时发布了大半年的 Vue.js2.x,当时的 Vue.js 远没这两年火,国内用的人还很少。例如当时的阿里云系统前端还是在用 AngularJS.1.x,阿里云已经算比较激进的一个了,很多大型网站都还停靠在 jQuery 的怀抱,虽然 React 发布了将近4年,国内的从业开发者也日益增长,但是现实中很多项目都大多停留在对 React 的摸索期,真正投入开发成熟的产品中,还是非常稀少的。Vue 能在这两个巨头中脱颖而出,肯定是有过人之处。

刚开始参与到 Vue 的项目开发中时,我印象最深刻的就是 JavaScript 语法规范: AirbnbStandard,当时项目里面选用的是 Standard 规范。接到第一个开发需求后,我就迫不及待地比划了几句装逼的代码,接着自信地按下 Ctrl + S,静待 Webpack 重新编译,迎接预期的效果渲染在浏览器页面上。谁知,控制台里面输出了一大堆错误。这是怎么回事,难道没配置 ES6 语法转换插件,导致我几句有逼格的代码不能被识别了?

仔细一看,发现不对劲。是一个叫 eslint 的插件抛出的错误。可是这报的都是什么错误呀?于是,马上咨询了一下“网络高级顾问度娘”,得到的答复大概如下:

  • 多余的行尾分号
  • 字符串不能用双引号
  • 文件最后要一个空行
  • 注释双斜杠后跟空格
  • 类函数声明间留空行
  • 操作符两端留空格
  • 使用===替代==
  • 存在已声明但未使用过的变量
  • ...

随后我向师兄确认了一下,他说,加入 eslint 插件是为了规范团队代码格式统一。但我吐了,这 eslint 插件的发明确定不是折磨开发者的吗?迫于无力反对下,我只好怪怪逐个排查代码语法格式,逐个修正。就这样,边写代码边报错,边查文档边纠错,过程令人烦躁,但时间长了,我竟然喜欢上了这种被规范代码格式的感觉!直至如今,我无论是写C#LuaOCPython 等,都会保留不少之前写 JavaScript 的一些规范。反观现在,只需一个 lint-fix 指令就能在秒级内修复代码中的语法格式问题,然而,这样会让很多初学者无法体会到自己逐步自我纠正,逐步规范化的过程。其实,如今越来越多自动化工具,都很容易让绝大部分的初学者失去了独立摸索的机会,而探索的过程,正是很好地让人渐渐地建立自己的主动性思维架构。

作为一名初学者,Vue 对我来说并不会产生陌生感,反而有些许亲切感的框架。我感觉它就是 AngularJS.1.xReact 的结合体,它里面的指令系统过滤器观察者模式等很像前者,而VDOM生命周期组件状态管理等很像后者,这是我当时对 Vue 最初最真实的认知。接触过前两者的我,肯定会不由自主地拿它跟“两位老前辈”相比的,因此也让豪哥(当时未接触vue)和朋哥(当时未接触React)觉得我很无聊。但1年后,也就是到了2018年,Web 前端三大框架的对比话题从未间断。你看,其实人性都喜欢拿相似的东西做去对比。任何存在同质化的领域,都逃不过被对比,人本身又何尝不是呢?

在实习的中前期,我除了用 Vue 去完成一些常规的研究院官网、众包平台、机器人创新平台、移动网页项目外,还花了大概两个月的时间,用 jQuery 成功地实现了配置系统的简易版,并顺利投入生产环境中,也算是弥补了之前失败过的遗憾。

和我一样在这家实习公司上班的,还有同班的其他9位小伙伴。实习的那段时间真的是充实而又轻松。我们当时是利用课余时间去实习的,所以,有课时可以到学校里去上课,没课才会过去实习,时间自由度非常大。像这种能让自身技术得到很好的磨练,又能赚到钱,又不会耽误学业的好事,也就只有当时的导师能给予我们了。另外,我的导师也非常器重我,因此,我也得到了很多锻炼机会。例如,我自己能像产品经理一样设计原型图、构想产品形态,又能像技术一样,实现产品落地,又能像项目经理一样参与合同签署和交付仪式等。很多超出了 Web 前端本身的职能,我在那时候都有幸体验过。你们可以理解为小公司里一人身兼多职是常态,但是遇到肯把机会留给自己的领导,真的很难得。

转眼间,时间轴来到了2017年的十月中旬,到了实习的中后期,是我的Web前端生涯一个至关重要的转折点。在这个时候,在我导师的带领下,我们组成了一支10个人的创业团队,名叫“黑胡子”,寓意“狼性”和“狂野”。目标是把配置平台的构思落地成 Sass和Pass 那样的云服务平台,下文统称“开发者平台”。没错,我又参与了创业项目!不过这一次,我们这10个团队成员,都是从原有的实习公司抽出来的骨干,代表着公司技术实力的最高象征。

我们这一群志同道合的年轻人,都不甘于平凡,都渴望着被社会认可,都梦想着能亲手开创下一个伟大的时代。这像极了绝大部分应届毕业生最初梦想的萌芽,我很庆幸,因为我曾经尝试过全力以赴去追寻它。

当时,考虑到浏览器的兼容性,前端技术栈选用了重 jQuery 和 轻 Vue 搭配,配合后端的模版渲染器,来达到兼容和性能的平衡。我负责的实体设计器表单设计器树状表格横表竖表等模块,是非常复杂且缺乏开源参考的,所以开发过程非常艰辛,开发进度非常难推进。很多方向都需要自己去思考和判断,很多方案都需要自己去尝试和验证,很多后果都需要自己评估和负责。这样,会导致大多时间都花费在试错上面。有人就会问,你们是个团队呀,不是应该由团队一起来解决吗?很抱歉,其他成员已经有他们同样需要挑战的领域了,况且他们也不了解这些方向的知识。其实在创业很多时候里,都需要团队成员自己独立去攻克自己负责的工作领域的难题。

在这段创业的日子里,团队成员除了完成日常的实习岗位工作任务之余,还要集中精力去研发开发者平台。大家都甘愿舍弃日常业余个人的时间,甚至牺牲假期时间来投入研发。没有补贴、没有奖励、没有怨言。每个人都朝着团队目标不断学习和进步,不断打磨理想中的产品。

经历了将近半年艰苦的研发,最终,市场还是选择了我们强大的竞争对手。这个事实很残酷,但是很真实。产品研发的理想主义者,往往在开发成本失控的情况下,白白断送了市场。而我这半年来,更加深化了自己的搜素能力学习能力解决问题的能力

到这里,很多人就会发现,我的创业和实习途中出现最多的人就是我的大学导师了。没错,我糊里糊涂走上了 Web 前端之路,他功不可没。如果用一句话形容他在我职业生涯中的地位,那就是:职业方向的灯塔。对于当代大学生来说,如果有贵人明确为你指明了方向,并且给予你足够多的帮助,那可是少走好几年弯路的啊!而现实大部分的情况却是,绝大部分的人糊里糊涂地选择了大学专业,又糊里糊涂地选择了就业岗位,逐渐迷失了人生职业的方向。

来到了2018年4月底,刚从创业中失败的我,不久就又面临着在实习公司是否如期转正的抉择。考虑到自身在实习公司到了技术瓶颈的阶段,又渴望着要见识外面的大千世界,最终我选择了放弃续签转正合同--裸辞。最后剩下一个多月的时间给自己,打算一边做毕业设计,一边找工作。计划归计划,可是人性懒惰呀。经历过将近两年艰辛的 Web 前端成长之路,突然有时间闲下来的我便立马放飞自我,除了花时间完成毕业论文和毕业设计之余,其余的业余时间都用来玩游戏(《刺激战场》)了。至于我做的是什么毕业设计?不要问,问就是抄袭了chrome浏览器一个叫"infinite"的插件,区别只是人家是用 Vue 实现的,而我是使用 React 高仿了一遍。

由于那一个月以来,我疏于 Web 前端技术基础的巩固训练,加上对自身技术经历的过于自信,以及对自身临场发挥的稳定性过于高估,导致在2018年5月底的一场CVTE内推面试中,遗憾落选。人生首次职场面试,以失败告终。到现在我都清楚记得三个技术问题分别是:

  • 第一道:JavaScript函数中的参数传参是“值传递”还是“引用传递”?为什么?
  • 第二道:CSS等间距、等分、无两边空系的布局题,要求是高兼容性方案。
  • 第三道:关于类似补全一个中间件的残缺代码块,让代码正常运行出预期的效果。

题目总体都不难,但我没有一道回答完全正确的,加上后两面回答HR的问题,答得也乱七八糟,用现在词语形容当时的我:“啥也不是!”

首次面试的失利,犹如一盘冷水泼在我脸上,顿时让狂妄自大的我,重新审视了自己的综合实力。于是,我开始逐步减少游戏时长,平时不爱看书的我,都开始啃红宝书等其他书籍,以此方式来疯狂补基础。其实我很早就意识到自己,没有对Web前端知识有立体性和系统性的认识,不少知识点都是从实战中摸索学习,然后拼凑到自己的前端知识体系中,从而形成了碎片化和断层化的认知。事实证明我的改变和重视,是颇有成效的!在后面的简历投放中,我有了50%机率进入面试阶段,而在后面的面试中,我又有75%机率拿到了offer。至此,不禁感慨道:机会不一定总是留给有准备的人,但是一定不会留给没准备的人。

四次面试下来,我拒绝了一家在深圳处于B轮融资的直播公司的offer(里面真的有很多漂亮的小姐姐,这也太诱人了吧)和一家在深圳做数据可视化的创业公司的offer,最后,选择了在我大学所在地广州的一家大型游戏公司《广州四三九九信息科技有限公司》。原因很简单,因为我想进规模大一点的厂。

2018年6月20日,我准时来到了四三九九(下文统一简称:四三)报到。第一次入职人数规模达1000+的游戏公司,心里还是非常激动和紧张的。新的办公环境敞亮整洁,新的同事热情健谈,那时是我初入职场中全新的起点。入职当天晚上有一个小插曲,鹅厂的电话面试邀请打来了,我承认我当时有些心动,但后面慎重考虑了一下还是拒绝了HR的所有邀请,打算先安心呆在了四三。正所谓:“既来之则安之”,再加上两个多星期下来的面试奔波,广州深圳两地来回跑,真的是累了。不过,刚挂断电话的我,手还是颤抖着的,而且心情还有些复杂,希望日后的我每次回想起这段回忆,都能够不悔当初的选择。

我被安排到一个叫“GDC”的部门中的 Web 前端小组,GDC 是 “Game Design Center" 的缩写,听说在页游时代立下了不少赫赫之功。部门大概由40个成员组成,主要负责的业务是公司的页游的活动页面的设计、开发,广告页面的创作、以及买量视频的制作,当然也有少量的手游需求,例如:微信小程序、微信小程序游戏、移动H5页面等。

新人入职,最大的幸运莫过于得到老同事的提携和重视。小到公司的考勤、伙食、行政单处理,大到公司组织架构、福利、战略、过去和未来,热情的同事都毫不吝啬地为我分享他们的心得和体会。

以前听得多公司体量大了,员工的职能和职责大多都像螺丝钉。无一例外,四三的岗位分工也非常明确和精细,就拿我当时入职岗位来说,我被安排的需求单里面,大多都是重构单。什么是重构呢?就是你只需要完成页面的静态内容和少量的 JavaScript 逻辑,剩下那些复杂的逻辑书写和数据接口对接,下一环流程都会有专门的人跟进,我只需要将 HTMLCSS 等静态页面的实现做到极致就可以了。这里的“极致”是怎么定义的呢?我总结为大概如下三点:

  • 兼容IE7、IE8。
  • 即使 CSS 文件加载失败了,网页文档基本布局不变,也就是语义化HTML,而不是无脑堆砌 DIV
  • 高度还原设计师的 PSD 设计稿,像素级做到不误差,并且能为页面加入合适的交互动画效果。

一开始,我承认我是有些瞧不起这个重构岗位的,甚至有种像是被蒙骗进来的感觉。但是后来我慢慢发觉,这个我看似简单的东西,如果我要做到和其他同事(老前辈)相当的重构效果,就要花更多的时间和精力,最后成果还不一定能与前辈们媲美。特别在页面的规划布局、细节展现和用户交互方面,纵使我不断偷师、不断模仿分析其他同事的静态页面,但我感觉总比他们少了点“灵魂”之类的东西,难以形容,可是自己能感觉的到。那是我第一次切身体会到,真的不要看轻任何一个岗位工作,因为你永远不知道把某件事情做到极致的人,在这个领域是怎样轻易地碾压着你,所以,我们永远都要怀着一颗学徒的心。

入职初期的2-3个月里面,我积极参与了公司大大小小的新人培训活动和课堂,发现公司的核心其实都是围绕着游戏研发方向的,很少提及到 Web 领域。一开始我还不太在意,因为当时我认为我已经处在一个足够令自己满意的 Web 技术氛围,这得益于我的顶头上司--勇哥,他也是我当初过来公司应聘时的主面试官。“进好的公司不如进好的团队,进好的团队不如有好的领导”。而勇哥,他是我 Web 前端生涯中的第二座灯塔

2018年8月中旬,勇哥鼓励下属的我们积极参加在广州举行的**首届React开发者大会,并通过他的推荐下,以比门市价便宜几百块钱的价格,买下了早鸟票。同月18号,在勇哥的带领下,我们如期参与这场开发者大会。大会上来了很多行业大咖,我觉得最有趣的是克君老师提出的“有限状态机”,而印象最深刻的是题叶老师将 Virtual DOM 的**,应用在多端在线同步编辑领域。

在 Web 前端领域,勇哥一直指引着团队前进的方向。

LeetCode(力扣)答案解析(七)

236. 二叉搜索树的最近公共祖先

给定一个二叉搜索树, 找到该树中两个指定节点的最近公共祖先。

百度百科中最近公共祖先的定义为:“对于有根树 T 的两个结点 p、q,最近公共祖先表示为一个结点 x,满足 x 是 p、q 的祖先且 x 的深度尽可能大(一个节点也可以是它自己的祖先)。”

例如,给定如下二叉搜索树: root = [6,2,8,0,4,7,9,null,null,3,5]

       _______6______
       /              \
    ___2__          ___8__
   /      \        /      \
   0      _4_      7       9
         /  \
         3   5

示例1:

输入: root = [6,2,8,0,4,7,9,null,null,3,5], p = 2, q = 8
输出: 6 
解释: 节点 2 和节点 8 的最近公共祖先是 6

示例2:

输入: root = [6,2,8,0,4,7,9,null,null,3,5], p = 2, q = 4
输出: 2
解释: 节点 2 和节点 4 的最近公共祖先是 2, 因为根据定义最近公共祖先节点可以为节点本身。

说明:

  • 所有节点的值都是唯一的。
  • p、q 为不同节点且均存在于给定的二叉搜索树中。

解法一(递归)

/**
 * Definition for a binary tree node.
 * function TreeNode(val) {
 *     this.val = val;
 *     this.left = this.right = null;
 * }
 */
/**
 * @param {TreeNode} root
 * @param {TreeNode} p
 * @param {TreeNode} q
 * @return {TreeNode}
 */
var lowestCommonAncestor = function(root, p, q) {
    if (root === null || root === p || root === q) {
        return root;
    }
    // 若q和p分别在root左右两边
    if ((root.val > p.val && root.val < q.val) ||
        (root.val < p.val && root.val > q.val)) {
        return root;
    }
    // 若q和p都在root的左边
    if (p.val < root.val && q.val < root.val) {
        return lowestCommonAncestor(root.left, p, q);
    } else {
        return lowestCommonAncestor(root.right, p, q);
    }
};

解析:

这种递归的解法,抓住二叉搜索树的特点(根节点,比其所有左子树上的所有节点大,比其所有右子节点上的节点小)。

  • 我们先判断根节点是否为空,或者是否等于q和p节点其中之一。
  • 然后我们判断q、p节点是分别在root节点的左右两边。若满足这个条件,这时候就可以返回root了。
  • 最后我只需判断q和p节点,是都在root节点的左边,还是在root节点的右边,然后递归者三个步骤,就得出答案了。

这种方法写法简洁,但是递归毕竟是递归,非常吃内存消耗。若这棵二叉搜索树层次非常深,递归方式将会产生非常深的调用栈,对于我们这种对性能追求极致的"极客"来说,是容忍不了。那么我们看看下面比较通用的、非递归的解法。

解法二(非递归)

/**
 * Definition for a binary tree node.
 * function TreeNode(val) {
 *     this.val = val;
 *     this.left = this.right = null;
 * }
 */
/**
 * @param {TreeNode} root
 * @param {TreeNode} p
 * @param {TreeNode} q
 * @return {TreeNode}
 */
var lowestCommonAncestor = function(root, p, q) {
    if (root == null || p == null || q == null) {
        return root;
    }
    var queue = [],
        nodes = [],
        rear = -1,
        front = -1;
    nodes.push(root);
    queue.push(-1);
    rear++;
    while (front != rear) {
        front++;
        var node = nodes[front];
        if (node.left) {
            nodes.push(node.left);
            queue.push(front);
            rear++;
        }
        if (node.right) {
            nodes.push(node.right);
            queue.push(front);
            rear++;
        }
    }
    var pIndex = null, qIndex = null;
    for (var i = 0, len = nodes.length; i < len; i++) {
        if (p.val === nodes[i].val) {
            pIndex = i;
        } else if (q.val === nodes[i].val) {
            qIndex = i;
        }
    }
    var queue1 = [],
        queue2 = [];
    while (pIndex != -1 || qIndex != -1) {
        if (pIndex != -1) {
            queue1.push(pIndex);
            pIndex = queue[pIndex];   
        }
        if (qIndex != -1) {
            queue2.push(qIndex);
            qIndex = queue[qIndex];
        }
    }
    var foundIndex = -1;
    for (var i = 0, len1 = queue1.length; i < len1; i++) {
        for (var j = 0, len2 = queue2.length; j < len2; j++) {
            if (queue1[i] === queue2[j]) {
                foundIndex = queue1[i];
                break;
            }
        }
        if (foundIndex > -1) {
            break;
        }
    }
    return foundIndex > -1 ? nodes[foundIndex] : null;
};

215. 数组中的第K个最大元素

在未排序的数组中找到第 k 个最大的元素。请注意,你需要找的是数组排序后的第 k 个最大的元素,而不是第 k 个不同的元素。

示例1:

输入: [3,2,1,5,6,4]  k = 2
输出: 5

示例2:

输入: [3,2,3,1,2,4,5,5,6]  k = 4
输出: 4

你可以假设 k 总是有效的,且 1 ≤ k ≤ 数组的长度。

这道题目并不困难,将数组从大到小排序后,然后找出下标为 k - 1 的元素就可以了,这里我介绍三种基础且常见的排序算法。

冒泡排序:

/**
 * @param {number[]} nums
 * @param {number} k
 * @return {number}
 */
var findKthLargest = function(nums, k) {
    var len = nums.length;
    for (var i = 0; i < len - 1; i++) {
        for (var j = 0; j < len - i - 1; j++) {
            if (nums[j] < nums[j + 1]) {
                var temp = nums[j];
                nums[j] = nums[j + 1];
                nums[j + 1] = temp;
            }
        }
    }
    return nums[k - 1];
};

选择排序:

/**
 * @param {number[]} nums
 * @param {number} k
 * @return {number}
 */
var findKthLargest = function(nums, k) {
    var len = nums.length;
    for (var i = 0; i < len; i++) {
        var max = nums[i], z = i;
        for (var j = i; j < len; j++) {
             if (nums[j] > max) {
                 max = nums[j];
                 z = j;
             }
        }
        if (z !== i) {
            var temp = nums[i];
            nums[i] = nums[z];
            nums[z] = temp;
        }
    }
    return nums[k - 1];
};

插入排序:

/**
 * @param {number[]} nums
 * @param {number} k
 * @return {number}
 */
var findKthLargest = function(nums, k) {
    var len = nums.length;
    for (var i = 1; i < len; i++) {
        var j = i - 1, temp = nums[i];
        while (j > -1 && temp > nums[j]) {
            nums[j + 1] = nums[j];
            j--;
        }
        nums[j + 1] = temp;
    }
    return nums[k - 1];
};

122. 买卖股票的最佳时机 II

给定一个数组,它的第 i 个元素是一支给定股票第 i 天的价格。

设计一个算法来计算你所能获取的最大利润。你可以尽可能地完成更多的交易(多次买卖一支股票)。

注意: 你不能同时参与多笔交易(你必须在再次购买前出售掉之前的股票)。

示例 1:

输入: [7,1,5,3,6,4]
输出: 7
解释: 在第 2 天(股票价格 = 1)的时候买入,在第 3 天(股票价格 = 5)的时候卖出, 这笔交易所能获得利润 = 5-1 = 4 
     随后,在第 4 天(股票价格 = 3)的时候买入,在第 5 天(股票价格 = 6)的时候卖出, 这笔交易所能获得利润 = 6-3 = 3 

示例2:

输入: [1,2,3,4,5]
输出: 4
解释: 在第 1 天(股票价格 = 1)的时候买入,在第 5  (股票价格 = 5)的时候卖出, 这笔交易所能获得利润 = 5-1 = 4 
     注意你不能在第 1 天和第 2 天接连购买股票,之后再将它们卖出。
     因为这样属于同时参与了多笔交易,你必须在再次购买前出售掉之前的股票。

示例3:

输入: [7,6,4,3,1]
输出: 0
解释: 在这种情况下, 没有交易完成, 所以最大利润为 0

题解(贪心算法):

/**
 * @param {number[]} prices
 * @return {number}
 */
var maxProfit = function(prices) {
    var maxPro = 0, tmp = 0;
    for (var i = 1, len = prices.length; i < len; i++) {
        tmp = prices[i] - prices[i - 1];
        if (tmp > 0) {
            maxPro += tmp;
        }
    }
    return maxPro;
};

解析:

贪心算法,在对问题求解时,总是做出在当前看来是最好的选择。也就是说,不从整体最优上加以考虑,他所做出的是在某种意义上的局部最优解。

可以看出,我们总是求解 maxPro > 0 的时候,因为这个是赚钱的情况。只要满足 maxPro > 0,那么证明股票这时候买入卖出就会赚,而且会又叠加效应,否则,并不会去股票交易,因为会出现亏损。

JavaScript 链表

前言

在之前的章节中,我们讨论了如何使用数组来实现列表队列等数据结构。本章节,我们讨论另一种列表:链表。我们将会认识到为什么有时候,链表会优于数组,还会实现一个基于对象的链表,并且附上一些实战内容。

背景

数组并不总是组织数据的最佳数据结构,原因如下。在很多编程语言中,数组的长度是固定的,所以当数组已被数据填满时,再要加入新的元素就会非常困难。在数组中,添加和删除元素也很麻烦,因为需要将数组中的其他元素向前或向后平移,以反映数组刚刚进行了添加或删除操作。然而,JavaScript 的数组并不存在上述问题,因为使用 split() 方法不需要再访问数组中的其他元素了。

JavaScript 中数组的主要问题是,它们被实现成了对象,与其他语言(比如 C++ 和 Java)的数组相比,效率很低。

如果你发现数组在实际使用时很慢,就可以考虑使用链表来替代它。除了对数据的随机访问,链表几乎可以用在任何可以使用一维数组的情况中。如果需要随机访问,数组仍然是更好的选择。

定义

链表是一种物理存储单元上非连续、非顺序的存储结构,数据元素的逻辑顺序是通过链表中的指针链接次序实现的集合。如下图所示:

default

数组元素靠它们的位置进行引用,链表元素则是靠相互之间的关系进行引用。然而要标识出链表的起始节点却有点麻烦,许多链表的实现都在链表最前面有一个特殊节点,叫做头节点。如下图所示:

default

链表中插入一个节点的效率很高。向链表中插入一个节点,需要修改它前面的节点(前驱),使其指向新加入的节点,而新加入的节点则指向原来前驱指向的节点。下图演示了如何在 Tue 节点后加入 Fri 节点。

default

从链表中删除一个元素也很简单。将待删除元素的前驱节点指向待删除元素的后继节点,同时将待删除元素指向 null,元素就删除成功了。下图演示了从链表中删除“Fri”节点的过程。

default

链表节点(Node)类实现

完整代码地址,Node类包含两个属性:

  • el 用来保存节点上的数据
  • next 用来保存指向下一个节点的链接

1. 构造函数

function Node (el) {
    this.el = el;
    this.next = null;
}

链表(Link)类实现

完整代码地址,Link类提供了以下的方法:

  • insert 插入新节点
  • remove 删除节点
  • display 显示链表元素的方法
  • 其他一些辅助方法

1. 构造函数

function Link () {
    this.head = new Node('head');
}

2. find:按节点的值查找节点

Link.prototype.find = function (el) {
    var currNode = this.head;
    while (currNode && currNode.el != el) {
        currNode = currNode.next;
    }
    return currNode;
}

find 方法展示了如何在链表上进行移动。首先,创建一个新节点,并将链表的头节点赋给这个新创建的节点。然后在链表上进行循环,如果当前节点的 el 属性和我们要找的信息不符,就从当前节点移动到下一个节点。如果查找成功,该方法返回包含该数据的节点;否则,返回 null

3. insert:插入一个节点

Link.prototype.insert = function (newEl, oldEl) {
    var newNode = new Node(newEl);
    var findNode = this.find(oldEl);
    if (findNode) {
        newNode.next = findNode.next;
        findNode.next = newNode;
    } else {
        throw new Error('找不到给定插入的节点');
    }
}

find 方法一旦找到给定的节点,就可以将新节点插入链表了。

4. display:展示链表节点元素

// 展示链表中的元素
Link.prototype.display = function () {
    var currNode = this.head.next;
    while (currNode) {
        console.log(currNode.el);
        currNode = currNode.next;
    }
}

5. findPrev:寻找给定节点的前一个节点

Link.prototype.findPrev = function (el) {
    var currNode = this.head;
    while (currNode.next && currNode.next.el !== el) {
        currNode = currNode.next;
    }
    return currNode;
}

6. remove:删除给定的节点

Link.prototype.remove = function (el) {
    var prevNode = this.findPrev (el);
    if (prevNode.next != null) {
        prevNode.next = prevNode.next.next;
    } else {
        throw new Error('找不到要删除的节点');
    }
}

链表(Link)类测试

var link = new Link();
link.append(1);
link.append(3);
link.display();
console.log('------------'); 
link.insert(2, 1);
link.display();
console.log('------------'); 
link.remove(1);
link.display();

运行结果:

1
3
------------
1
2
3
------------
2
3

上面介绍的链表我们称作:单向链表(单链表),其特点是链表的链接方向是单向的,对链表的访问要通过顺序读取从头部开始。下面我们介绍另一种链表:双向链表(双链表)

双向链表

双向链表也叫双链表,是链表的一种,它的每个数据结点中都有两个指针,分别指向直接后继和直接前驱。所以,从双向链表中的任意一个结点开始,都可以很方便地访问它的前驱结点和后继结点。如下图所示:

default

双向链表节点(DNode)类实现

完整代码地址,相比于单向链表节点(Node)类,我们只需新增一个 prev 属性,指向之前一个链表节点的引用即可。

function DNode (el) {
    this.el = el;
    this.prev = null;
    this.next = null;
}

双向链表(DLink)类实现

完整代码地址,相比于单链表,双链表的操作会复杂一点。

1. 构造函数

function DLink () {
    this.head = new DNode('head');
}

2. append:向链表结尾添加一个节点

DLink.prototype.append = function (el) {
    var currNode = this.head;
    while (currNode.next != null) {
        currNode = currNode.next;
    }
    var newNode = new DNode(el);
    newNode.next = currNode.next;
    newNode.prev = currNode;
    currNode.next = newNode;
}

3. find:查找给定的节点

DLink.prototype.find = function (el) {
    var currNode = this.head;
    while (currNode && currNode.el != el) {
        currNode = currNode.next;
    }
    return currNode;
}

4. insert:插入一个节点

DLink.prototype.insert = function (newEl, oldEl) {
    var newNode = new DNode(newEl);
    var currNode = this.find(oldEl);
    if (currNode) {
        newNode.next = currNode.next;
        newNode.prev = currNode;
        currNode.next = newNode;
    } else {
        throw new Error('未找到指定要插入节点位置对应的值!')
    }
}

5. display:顺序展示链表节点

DLink.prototype.display = function () {
    var currNode = this.head.next;
    while (currNode) {
        console.log(currNode.el);
        currNode = currNode.next;
    }
}

6. findLast:查找最后一个节点

DLink.prototype.findLast = function () {
    var currNode = this.head;
    while (currNode.next != null) {
        currNode = currNode.next;
    }
    return currNode;
}

7. dispReverse:逆序展示链表元素

DLink.prototype.dispReverse = function () {
    var currNode = this.head;
    currNode = this.findLast();
    while (currNode.prev != null) {
        console(currNode.el);
        currNode = currNode.prev;
    }
}

8. remove:删除节点

DLink.prototype.remove = function (el) {
    var currNode = this.find(el);
    if (currNode && currNode.next != null) {
        currNode.prev.next = currNode.next;
        currNode.next.prev = currNode.prev;
        currNode.next = null;
        currNode.previous = null;
    } else {
        throw new Error('找不到要删除对应的节点');
    }
}

双向链表(DLink)类测试

// 实例化
var doubleLink = new DLink();
doubleLink.append(1);
doubleLink.append(2);
doubleLink.append(4);
doubleLink.display();
console.log('-------------------------');
doubleLink.dispReverse();
console.log('-------------------------');
doubleLink.insert(3, 2);
doubleLink.display();
doubleLink.remove(1);
console.log('-------------------------');
doubleLink.display();

运行结果:

1
2
4
-------------------------
4
2
1
-------------------------
1
2
3
4
-------------------------
2
3
4

循环链表

循环链表和单向链表相似,节点类型都是一样的。唯一的区别是,在创建循环链表时,让其头节点的 next 属性指向它本身,即:this.head.next = this.head,并保证链表中最后一个节点的 next 属性,始终指向
head,如下图所示:

循环链表

循环链表(CLink)实现

1. 构造函数

完整代码地址,这里,我们沿用单链表中的节点(Node)类,做为循环链表的节点类。不同的是,我们在 CLink 构造函数阶段,就要把 this.head 赋值给 this.head.next

function CLink () {
    this.head = new Node('head');
    this.head.next = this.head;
}

2. append: 向链表节点增加一个元素

CLink.prototype.append = function (el) {
    var currNode = this.head;
    while (currNode.next != null) {
        currNode = currNode.next;
    }
    var newNode = new Node(el);
    newNode.next = currNode.next;
    currNode.next = newNode;
}

3. find:根据节点的值查找链表节点

CLink.prototype.find = function (el) {
    var currNode = this.head;
    while (currNode && currNode.el != el) {
        currNode = currNode.next;
    }
    return currNode;
}

4. insert:插入一个节点

CLink.prototype.insert = function (newEl, oldEl) {
    var newNode = new Node(newEl);
    var currNode = this.find(oldEl);
    if (currNode) {
        newNode.next = currNode.next;
        currNode.next = newNode;
    } else {
        throw new Error('未找到指定要插入节点位置对应的值!');
    }
}

5. display:展示链表元素节点

CLink.prototype.display = function () {
    var currNode = this.head.next;
    while (currNode && currNode != this.head) {
        console.log(currNode.el);
        currNode = currNode.next;
    }
}

6. 根据给定值寻找前一个节点

CLink.prototype.findPrev = function (el) {
    var currNode = this.head;
    while (currNode.next && currNode.next.el !== el) {
        currNode = currNode.next;
    }
    return currNode;
}

7. 删除给定值对应的节点

CLink.prototype.remove = function (el) {
    var prevNode = this.findPrev(el);
    if (prevNode.next != null) {
        prevNode.next = prevNode.next.next;
        prevNode.next.next = null;
    } else {
        throw new Error('找不到要删除的节点');
    }
}

循环链表(CLink)类测试

var circleLink = new CLink ();
circleLink.append(1);
circleLink.append(2);
circleLink.append(4);
circleLink.display();
console.log('---------------------------');
circleLink.insert(3, 2);
circleLink.display();
console.log('---------------------------');
circleLink.remove(4);
circleLink.display();

运行结果:

1
2
4
---------------------------
1
2
3
4
---------------------------
1
2
3

链表实战

题目: 传说在公元 1 世纪的犹太战争中,犹太历史学家弗拉维奥·约瑟夫斯和他的 40 个同胞被罗马士兵包围。犹太士兵决定宁可自杀也不做俘虏,于是商量出了一个自杀方案。他们围成一个圈,从一个人开始,数到第三个人时将第三个人杀死,然后再数,直到杀光所有人。约瑟夫和另外一个人决定不参加这个疯狂的游戏,他们快速地计算出了两个位置,站在那里得以幸存。写一段程序将 n 个人围成一圈,并且第 m 个人会被杀掉,计算一圈人中哪两个人最后会存活。使用循环链表解决该问题。

题解:

function survival (n, m) {
    if (n <= 2) {
        return;
    }
    var clink = new CLink();
    for (var i = 1; i <= n; i++) {
        clink.append(i);
    }
    var p = clink.head,
        count = 0;
    while (n > 2) {
        p = p.next;
        if (p === clink.head) {
            continue;
        }
        count++;
        if (count === m) {
            clink.remove(p.el);
            count = 0;
            n--;
        }
    }
    console.log('幸存者:');
    clink.display();
}

测试:

survival(10, 3);

运行结果:

幸存者:
4
10

解析:

首先,我们把 n 个人按1-n的序号排好,然后按照每第 m 个人死亡来循环遍历循环链表,直到剩下两个人为止。

1 2 3 4 5 6 7 8 9 10
    x     x     x  
  x         x       
x             x    
        x

前言

什么是数据结构和算法?

笼统来说,在日常业务处理中,数据结构是指我们用什么东西(可以是普通的值、数组、链表、对象、集合、树、图等)来存储处理这个业务问题过程中的数据。算法是指我们是怎么去解决一个业务问题(用什么方式去处理)。

用专业术语描述的话,数据结构是指数据之间的结构关系,或者理解成数据元素相互之间存在的一种或多种特定关系的集合。算法是指解题方案的准确而完整的描述,是一系列解决问题的清晰指令,算法代表着用系统的方法描述解决问题的策略机制。

好吧,听起来都可能比较抽象,举个更直观的例子:我现在想去北京旅游,那么我可以自驾、可以坐火车、可以坐高铁、也可以坐飞机等等,这里提到的交通工具本身(火车、飞机等)叫数据结构,而坐什么交通工具(出行方式)的这个描述,叫做算法。所以,我们可以看出,不同的算法(出行方式),性能(速度)是不一样,有快慢之分;不同的数据结构(交通工具),存储形式(站票、软卧等)也是不一样。

你看,这样类比的话,你可以发现生活中充满着类似”算法和数据结构“关系形式的事物存在,这也是为什么提到算法,一般会附带上数据结构;一提到数据结构,算法也随之带上,它们的关系就如"飞机和坐飞机"的关系。其实,很多计算机概念都是源于生活启发,源于数学理论的。

为什么要学数据结构和算法

这个问题,很多师弟师妹、其他小伙伴、以及很多面试官都曾问过我,我觉得我一直都没答好这个问题,但我也相信,很少人能回答好这个问题。与其说不能回答好这个问题,还不如说,基本上很少人去真正去思考过这个问题,究竟数据结构和算法,在程序设计中,是以怎样具象的东西存在,能让我们实实在在的感受到它存在的威力。

我为什么突出”具象“这个词呢,因为在学校里面,老师在教科书上,或者在线教学视频中的授课,都不能让学生,能有一种”质感“形式去体会到编程中数据结构和算法的存在,大多都停留在枯燥的概念,和生搬硬套的练习。

我身边有这么一位可爱女生,她为了”数据结构和算法“这门课能通过,把老师课上画出来的考试重点的题目中用到的代码都背下来了,最后还考了不错的成绩。当时我听到后,都惊呆了,我并不是佩服她这种”*操作“,而是思考”为什么有这种功夫去硬背代码,却不去折腾怎么让自己理解好每个算法?“。这个时候,往往我得到的解释大多都是:上课听不懂、没认真听讲、不感兴趣等。甚至,有些已经工作好些年的同学,都会觉得"算法和数据结构"是一个很虚的东西,有没有都不影响日常工作,没必要去专门学习,更别提深入理解了。

我承认,日常业务开发中,很多数据结构和算法,都在第三方库或者项目组中的utils工具库封装得很好了,知道怎么去使用,就可以让开发行云流水了。不过,经过我这些年来的观察,有没有经过”数据结构和算法“熏陶的人,写出来的代码是有很大区别的,甚至天壤之别!有没有?有没有同感的!?这种区别我大致归纳为:

  • 不怎么懂的人: 代码篇幅偏臃肿,逻辑偏紊乱,代码嵌套偏深,代码组织结构偏乱,代码可扩展性偏弱,bug偏多,编程思维和业务处理能力偏弱。
  • 懂的人: 代码特点较精炼、简洁、逻辑清晰、模块高内聚低耦合、bug较偏少、思维偏差较少。

我并没有刻意其抬高懂"数据结构和算法"的人,有多牛逼,多么高人一等,编程思维和能力真的有强弱之分,这个就是要学好”数据结构和算法“的原因之一了。就好比会武功的人,懂不懂内功,往往能决定自身功夫能达到的最高境界。就如,我当时给出的回答很多时候是:”XXX 师弟/师妹,你不学好数据结构和算法,在编程的路上是走不远的“。学习“数据结构和算法”,是能锻炼人的编程能力的,而我们大家都知道,能力需要培养的。

实际上,我还发现,有良好的程序设计基础的人,往往更能够适应多变的业务,甚至更轻松做到技术栈跨域这个过程。因为程序设计中充满着归纳、递推、分而治之、动态规划等**,而业务中也充满着需要举一反三的情景,这个时候是需要程序员经过了良好的计算机科学教育,才能胜任这种变化已成常态的业务需求。所以,学好“数据结构和算法”更能适应多变的业务需求。

计算机科学家尼克劳斯·沃思认为程序 = 数据结构 + 算法,相信很多人都听过或者已经见过这个公式了,但对于很多初学编程者来说,是一条令人毫无触动的公式,因为他们眼里的程序,还没真正面对复杂的计算和业务。而对于已经工作了的同学,往往会觉得程序 = 需求 + 业务更恰当,这或许就是绝大工作环境影响下导致的认知。

然而,当你业务不再是日常的普通流程、机械重复的工作、模板化的生产,而是要开发一个工具库、或者底层开发、或者数据库内核开发、冷门业务开发等领域,都需要良好的程序设计基础,这条公式,就显得至关重要。一些开拓性、探索性的项目,就更需要良好的程序设计基础。

那么对于像我这样搞Web前端的同学来说,又不是搞什么底层的东西,日常业务又不用接触多复杂的计算,过于关注”数据结构和算法“是不是在装X呢?

不然,因为如果像我这样在实际工作业务(上面提到的冷门业务开发)中,使用了大量的数据结构和算法的同学,必定也深有体会”数据结构和算法”是多么重要的,因为它真的会帮我们解决实实在在的业务问题和技术难题。而且,你可以把“数据结构和算法”当成自己的一种核心竞争力!

其实,在编程中,“数据结构和算法”无处不在,简单的数组就可以当作一种数据结构了,简单的流程控制语句if、for、while就可以当作一种算法了,它们很不起眼,但是,我们很少人会去思考怎么去更好地组织数据结构、运算数据罢了。

怎么学习数据结构和算法?

我这里有一本电子书:《数据结构和算法JavaScript描述》, 或者关注我后续更新的内容,我会归纳精简一些有意思的内容,来减轻读者的压力!

JS怎么准确判断数据类型?

前言

ES5 中有五种基本(原始)数据类型undefinednullbooleannumberstring,ES6 中新增了一种基本数据类型:Symboltypeof是我们开发中最常用的判断数据类型的JS原生内置运算符,但是有局限性。

typeof 运算符

语法:

typeof运算符后跟操作数:

typeof ${操作数}
// or
typeof (${操作数})

示例:

typeof(undefined); // undefined
typeof(null); // object
typeof(true); // boolean
typeof(1); // number
typeof(''); // string
typeof(Symbol(1)); // symbol
typeof(function () {}); // function
typeof([]); // object
typeof({}); // object
typeof(new Date()); // object
typeof(/abc/ig);  // object
typeof(Math);  // object
typeof(new Error('error')); // object

这里有两点需要注意的:

  • typeof null将返回object。因为在 JS 的最初版本中,使用的是 32 位系统,为了性能考虑使用低位存储了变量的类型信息,000 开头代表是对象,然而 null 表示为全零,所以将它错误的判断为 object,然后被 ECMAScript 沿用了 。
  • typeof不能准确判别对象类型究竟是什么具体对象。例如typeof {}typeof new Date(), typeof /abc/igtypeof Math,都是返回object,有没有可能告诉我们这是一个date对象,那是一个regexp对象呢?。还有一个不能忍受的是,typeof []也是返回object。很多时候,我们业务中希望能准确区分是array还是object

另外,instanceof 也可以判断对象类型,因为内部机制是通过判断对象的原型链中是不是能找到类型的 prototype。但是,并不适用于一些基本数据类型。

1 instanceof Number; // false
var num = new Number(1);
num instanceof Number; // true

思考

既然typeofinstanceof都有局限性,那么有没有一种相对准确的方法来判断数据类型呢?答案是肯定的,它就是Object.prototype.toString.call(xxx)方法,其结果返回格式形如:[object Array][object RegExp][object Date]等。我们可以根据其表达式的返回结果中的中括号中的第二个单词,就能准确判别这个数据的具体类型。网上已有很多资料介绍这个函数的用法,它的表现形式也有很多种:

1. Object.prototype.toString.call(xxx);
2. ({}).toString.call(xxx);
3. [].toString.call(xxx);
...

其实,写法再多也是万变不离其。都是调用了原型链上的原生toString方法,来为数据类型做强制类型转化。

实践

场景一

如果我们只需要准确判断六种基本数据类型,同时又能够准确区分数据类型是functionarray、还是object就足够的话,那么我们可以这样实现:

var superTypeof = function (val) {
    var ans = typeof val;
    if (ans === 'object') {
        if (val === null) {
            ans = 'null';
        } else if (Array.isArray(val)) {
            ans = 'array';
        }
    }
    return ans;
}

ps: 如果有兼容性要求的同学,可以将Array.isArray(val)语句,改成val instanceof Array

测试

superTypeof(undefined); // undefined
superTypeof(null); // null
superTypeof(true); // boolean
superTypeof(1); // number
superTypeof(''); // string
superTypeof(Symbol(1)); // symbol
superTypeof(function () {}); // function
superTypeof([]); // array
superTypeof({}); // object
superTypeof(new Date()); // object
superTypeof(/abc/ig); // object
superTypeof(Math); // object
superTypeof(new Error('error')); // object
... 

场景二

某一天,我们发现,以上的superTypeof函数,并不能准确告诉我们,返回的 Object 类型究竟是Date还是RegExp还是其他比较具体的对象。这个时候,我们就需要用到上述提及的Object.prototype.toString.call(xxx)方法了。

var superTypeof = function (val) {
    var ans = typeof val;
    if (ans === 'object') {
        ans = ({}).toString.call(val).slice(8,-1).toLowerCase();
    }
    return ans;
}

测试:

superTypeof(undefined); // undefined
superTypeof(null); // null
superTypeof(true); // boolean
superTypeof(1); // number
superTypeof(''); // string
superTypeof(Symbol(1)); // symbol
superTypeof(function () {}); // function
superTypeof([]); // array
superTypeof({}); // object
superTypeof(new Date()); // date
superTypeof(/abc/ig); // regexp
superTypeof(Math); // math
superTypeof(new Error('error')); // error
...

通过这种方式,我们就能准确判断JS中的数据类型了。

jQuery 实现方式

我们再来看看jquery是怎么实现类似的功能的:

var class2type = {},
    typeStr = "Boolean Number String Function Array Date RegExp Object Error Symbol";
typeStr.split(" ").forEach(function (item) {
    class2type[ "[object " + item+ "]" ] = item.toLowerCase();
});
var toType = function (obj) {
    if ( obj == null ) {
        return obj + "";
    }
    return typeof obj === "object" || typeof obj === "function" ?
		class2type[ toString.call( obj ) ] || "object" :
		typeof obj;
}

是不是觉得大同小异的实现方式,甚至还不够我写得优雅呢?其实不然,这有jQuery作者的用意。

最后,我想安利一个有类似功能,且强大精简的库typeof2

JavaScript 集合

前言

集合(set)是一种包含不同元素的数据结构。集合中的元素称为成员。集合的两个最重要特性是:

  • 集合中的成员是无序的
  • 集合中不允许相同成员存在

集合在计算机科学中扮演了非常重要的角色,然而在很多编程语言中,并不把集合当成一种数据类型。当你想要创建一个数据结构,用来保存一些独一无二的元素时,比如一段文本中用到的单词,集合就变得非常有用。ES6中已经实现了Set类,那么这里,我们尝试自己去实现一个类似ES6中Set类。

定义

集合是由一组无序但彼此之间又有一定相关性的成员构成的,每个成员在集合中只能出现一次。在数学上,用大括号将一组成员括起来表示集合,比如 {0,1,2,3,4,5,6,7,8,9}。集合中成员的顺序是任意的,因此前面的集合也可以写做 {9,0,8,1,7,2,6,3,5,4},或者其他任意形式的组合,但是必须保证每个成员只能出现一次。

另外,下面是一些使用集合时必须了解的定义:

  • 不包含任何成员的集合称为 空集全集 则是包含一切可能成员的集合。
  • 如果两个集合的成员完全相同,则称两个集合相等。
  • 如果一个集合中所有的成员都属于另外一个集合,则前一集合称为后一集合的 子集

而对集合的基本操作有下面几种:

  • 并集:将两个集合中的成员进行合并,得到一个新集合。
  • 交集:两个集合**同存在的成员组成一个新的集合。
  • 补集:属于一个集合而不属于另一个集合的成员组成的集合。

集合(Set)类实现

1. 构造函数

完整代码地址

function Set (arr) {
    if (arr && !Array.isArray(arr)) {
        throw new Error('传入的参数是非数组类型!');
    }
    this.dataStore = [];
    if (arr.length) {
        var _this = this;
        arr.forEach(function (item) {
            _this.add(item);
        });
    }
}

2. add:向集合中添加元素

Set.prototype.add = function (data) {
    if (!this.has(data)) {
        this.dataStore.push(data);
        return true;
    }
    return false;    
}

3. has:判断某个元素是否在集合中存在

Set.prototype.has = function (data) {
    return this.dataStore.indexOf(data) > -1;
}

4. remove:删除集合中的某个元素

Set.prototype.remove = function (data) {
    var pos = this.dataStore.indexOf(data);
    if (pos > -1) {
        this.dataStore.splice(pos, 1);
        return true;
    }
    return false;
}

5. clear:清除集合中的所有成员

Set.prototype.clear = function () {
    delete this.dataStore;
    this.dataStore = [];
}

6. size:获取集合中的元素个数

Set.prototype.size = function () {
    return this.dataStore.length;
}

7. show:返回集合中的元素

Set.prototype.show = function () {
    return this.dataStore;
}

8. union:求集合的并集

Set.prototype.union = function (set) {
    var tempSet = new Set();
    this.dataStore.forEach(function (item) {
        tempSet.add(item);
    });
    set.show().forEach(function (item) {
        if (!tempSet.has(item)) {
            tempSet.add(item);
        }
    });
    // 返回集合并集
    return tempSet;
}

9. intersect:求集合的交集

Set.prototype.intersect = function (set) {
    this.dataStore.forEach(function (item) {
        if (set.has(item)) {
            tempSet.add(item);
        }
    });
    return tempSet;
}

10. subSet:判断一个集合是否是另一个集合的子集

Set.prototype.subSet = function (set) {
    if (this.size() > set.size()) {
        return false;
    } 
    this.dataStore.forEach(function (item) {
        if (!set.has(item)) {
            return false;
        }
    });
    return true;
}

11. difference::求集合与另一个集合的补集

Set.prototype.difference = function (set) {
    var tempSet = new Set();
    this.dataStore.forEach(function (item) {
        if (!set.has(item)) {
            tempSet.add(item);
        }
    });
    return tempSet;
};

集合(Set)类测试

var set = new Set([1, 2, 3])
console.log(set.show());
set.remove(3);
console.log(set.show());
set.clear();
console.log(set.show());
var set1 = new Set([1, 2, 3, 4]);
var set2 = new Set([3, 4, 5, 6]);
var unionSet = set1.union(set2);
console.log(unionSet.show());
var intersectSet = set1.intersect(set2);
console.log(intersectSet.show());
console.log(set.subSet(set1));
set.add(1);
set.add(2);
set.add(3);
var diffSet = set.difference(set2);
console.log(diffSet.show());

运行结果:

[ 1, 2, 3 ]
[ 1, 2 ]
[]
[ 1, 2, 3, 4, 5, 6 ]
[ 3, 4 ]
true
[ 1, 2 ]

集合应用

据我所知,集合在生产环境中,大多时候的作用是辅助去重的功能。

参考地址

JavaScript 动态规划&贪心算法

前言

这一章,我们将介绍另外两种常用的算法:动态规划贪心算法。动态规划常被人比作是递归的逆过程,而贪心算法在很多求优问题上,是不二之选。下面,我们针对这两种算法,展开详细的学习。

动态规划

动态规划有时为什么被认为是一种与递归相反的技术呢?是因为递归是从顶部开始将问题分解,通过解决掉所有分解出小问题的方式,来解决整个问题。动态规划解决方案从底部开始解决问题,将所有小问题解决掉,然后合并成一个整体解决方案,从而解决掉整个大问题。

使用递归去解决问题虽然简洁,但效率不高。包括 JavaScript 在内的众多语言,不能高效地将递归代码解释为机器代码,尽管写出来的程序简洁,但是执行效率低下。但这并不是说使用递归是件坏事,本质上说,只是那些指令式编程语言和面向对象的编程语言对递归 的实现不够完善,因为它们没有将递归作为高级编程的特性。

斐波拉契数列

斐波拉契数列定义为以下序列:

0,1,1,2,3,5,8,13,21,34,55,......

可以看到,当 n >= 2,an = an - 1 + an - 2。这个数列的历史非常悠久,它是被公元700年一位意大利数学家斐波拉契用来描述在理想状态下兔子的增长情况。

不难看出,这个数列可以用一个简单的递归函数表示。

function fibo (n) {
    if (n <= 0)  return 0;
    if (n === 1) return 1;
    return fibo(n - 1) + fibo(n - 2);
}

这种实现方式非常耗性能,在n的数量级到达千级别,程序就变得特别慢,甚至失去响应。如果使用动态规划从它能解决的最简单子问题着手的话,效果就很不一样了。这里我们先用一个数组来存取每一次产生子问题的结果,方便后面求解使用。

function fibo (n) {
    if (n <= 0) return 0;
    if (n <= 1) return 1;
    var arr = [0, 1];
    for (var i = 2; i <= n; i++) {
        arr[i] = arr[i - 1] + arr[i - 2];
    }
    return arr[n];
}

细心的同学发现,这里的数组可以去掉,换做局部变量来实现可以省下不少内存空间。

function fibo (n) {
    if (n <= 0) return 0;
    if (n <= 1) return 1;
    var res, a = 0, b = 1;
    for (var i = 2; i <= n; i++) {
        res = a + b;
        a = b;
        b = res;
    }
    return res;
}

这里实现方式还有没有可能更简洁呢?答案是肯定的,我可以再节省一个变量。

function fibo (n) {
    if (n <= 0) return 0;
    if (n <= 1) return 1;
    var a = 0, b = 1;
    for (var i = 2; i <= n; i++) {
        b = a + b;
        a = b - a;
    }
    return b;
}

寻找最长公共子串

另一个适合使用动态规划去解决的问题是寻找两个字符串的最长公共子串。例如,在单词 raven 和 havoc中,最长的公共子串是“av”。寻找最长公共子串常用于遗传学中,用于使用核苷酸中碱基的首字母对DNA分子进行描述。

我们可以用暴力法去解决这个问题,但显得很笨拙。

function maxSubString (str1, str2) {
    if (!str1 || !str2) return '';
    var len1 = str1.length,
        len2 = str2.length;
    var maxSubStr = '';
    for (var i = 0; i < len1; i++) {
        for (var j = 0; j < len2; j++) {
            var tempStr = '',
                k = 0;
            while ((i + k < len1) && (j + k < len2) && (str1[i + k] === str2[j + k])) {
                tempStr += str1[i + k];
                k++;
            }
            if (tempStr.length >  maxSubStr.length) {
                maxSubStr = tempStr;
            }
        }
    }
    return maxSubStr;
}

求最长公共子串的动态规划算法,我们并不展开,有兴趣的同学可以跳转至以下链接:

背包问题

背包问题是算法研究中的一个经典问题。试想你是一个保险箱大盗,打开了一个装满奇珍异宝的保险箱,但是你必须将这些宝贝放入你的一个小背包中。保险箱中的物品规格和价值不同。你希望自己的背包装进的宝贝总价值最大。

当然,暴力计算可以解决这个问题,但是动态规划会更为有效。使用动态规划来解决背包问题的关键思路是计算装入背包的每一个物品的最大价值,直到背包装满。

如果在我们例子中的保险箱中有 5 件物品,它们的尺寸分别是 3、4、7、8、9,而它们的价值分别是 4、5、10、11、13,且背包的容积为 16,那么恰当的解决方案是选取第三件物品和第五件物品,他们的总尺寸是 16,总价值是 23。

表1:0-1背包问题

物品 A B C D E
价值 4 5 10 11 13
尺寸 3 4 7 8 9

首先,我们看看递归方式怎么去解决这个问题:

function knapsack (capacity, objectArr, order) {
    if (order < 0 || capacity <= 0) {
        return 0;
    }
    if (arr[order].size > capacity) {
        return knapsack(capacity, objectArr, order - 1);
    }
    return Math.max(arr[order].value + knapsack(capacity - arr[order].size, objectArr, order - 1),
                    knapsack(capacity, objectArr, order - 1));
}

console.log(knapsack(16, [
    {value: 4, size: 3},
    {value: 5, size: 4},
    {value: 10, size: 7},
    {value: 11, size: 8},
    {value: 13, size: 9}
], 4)); // 23

为了提高程序的运行效率,我们不妨将递归实现方式改成动态规划。这个问题有个专业的术语:0-1背包问题。0-1背包问题,dp解法历来都困扰很多初学者,大多人学一次忘一次,那么,这次我们努力💪将它记在心里。

注意,理解0-1背包问题的突破口,就是要理解 “0-1” 这个含义,这里对于每一件物品,要么带走(1),要么留下(0)。

基本思路

0-1背包问题子结构:选择一个给定第 i 件物品,则需要比较选择第 i 件物品的形成的子问题的最优解与不选择第 i 件物品的子问题的最优解。分成两个子问题,进行选择比较,选择最优的。

若将 f[i][w] 表示前 i 件物品恰放入一个容量为 w 的背包可以获得的最大价值。则其状态转移方程便是:

f[i][w] = max{ f[i-1][w], f[i-1][w-w[i]]+v[i] }

其中,w[i] 表示第 i 件物品的重量,v[i] 表示第 i 件物品的价值。

function knapsack (capacity, objectArr) {
    var n = objectArr.length;
    var f = [];
    for (var i = 0; i <= n; i++) {
        f[i] = [];
        for (var w = 0; w <= capacity; w++) {
            if (i === 0 || w === 0) {
                f[i][w] = 0;
            } else if (objectArr[i - 1].size <= w) {
                var size = objectArr[i - 1].size,
                    value = objectArr[i - 1].value
                f[i][w] = Math.max(f[i - 1][w - size] + value, f[i - 1][w]);
            } else {
                f[i][w] = f[i - 1][w];
            }
        }
    }
    return f[n][capacity];
}

以上方法空间复杂度和时间复杂都是O(nm),其中 n 为物品个数,m 为背包容量。时间复杂度没有优化的余地了,但是空间复杂我们可以优化到O(m)。首先我们要改写状态转移方程:

f[w] = max{ f[w], f[w-w[i]]+v[i] }

请看代码示例:

function knapsack (capacity, objectArr) {
    var n = objectArr.length;
    var f = [];
    for (var w = 0; w <= capacity; w++) {
        for (var i = 0; i < n; i++) {
            if (w === 0) {
                f[w] = 0;
            } else if (objectArr[i].size <= w) {
                var size = objectArr[i].size,
                    value = objectArr[i].value
                f[w] = Math.max(f[w - size] + value, f[w] || 0);
            } else {
                f[w] = Math.max(f[w] || 0, f[w - 1]);
            }
        }
    }
    return f[capacity];
}

贪心算法

前面研究的动态规划算法,它可以用于优化通过次优算法找到的解决方案——这些方案通常是基于递归方案实现的。对许多问题来说,采用动态规划的方式去处理有点大材小用,往往一个简单的算法就够了。

贪心算法就是一种比较简单的算法。贪心算法总是会选择当下的最优解,而不去考虑这一次的选择会不会对未来的选择造成影响。使用贪心算法通常表明,实现者希望做出的这一系列局部“最优”选择能够带来最终的整体“最优”选择。如果是这样的话,该算法将会产生一个最优解,否则,则会得到一个次优解。然而,对很多问题来说,寻找最优解很麻烦,这么做不值得,所以使用贪心算法就足够了。

背包问题

如果放入背包的物品从本质上说是连续的,那么就可以使用贪心算法来解决背包问题。换句话说,该物品必须是不能离散计数的,比如布匹和金粉。如果用到的物品是连续的,那么可以简单地通过物品的单价除以单位体积来确定物品的价值。在这种情况下的最优 是,先装价值最高的物品直到该物品装完或者将背包装满,接着装价值次高的物品,直到这种物品也装完或将背包装满,以此类推。我们把这种问题类型叫做 “部分背包问题”。

表2:部分背包问题

物品 A B C D E
价值 50 140 60 60 80
尺寸 5 20 10 12 20
比率 10 7 6 5 4

我们不能通过贪心算法来解决离散物品问题的原因,是因为我们无法将“半台电视”放入背包。换句话说,贪心算法不能解决0-1背包问题,因为在0-1背包问题下,你必须放入整个物品或者不放入。

function knapsack (capacity, objectArr) {
    // 首先按性价比排序, 高 -> 低
    objectArr.sort(function (a, b) {
        return parseFloat(b.value / b.size) - parseFloat(a.value / a.size);
    });
    // 记录物品个数
    var n = objectArr.length;
    // 记录已经选中尺寸,已选最大的最大价值
    var selected = 0,
        maxValue = 0;
    for (var i = 0; i < n && selected < capacity; i++) {
        var size = objectArr[i].size,
            value = objectArr[i].value;
        if (size <= capacity - selected) {
            maxValue += value;
            selected += size;
        } else {
            // 计算比例
            maxValue += value * ((capacity - selected) / size);
            selected  = capacity;
        }
    }
    return maxValue;
}

参考链接

HikariObfuscator操作手册

Hikari Obfuscator

Hikari Obfuscator 是一款出色的代码编译混淆器,作用在xcode的build阶段,若使用不当可能会影响App性能

简介

源码地址

Hikari比Obfuscator-LLVM有一些额外的自定义构建通道和(希望不是)bug。测试是通过在作者的项目WallpaperKit上运行Hikari来完成的。

安装

安装详情

前置条件

安装前,先确认自己的系统已经安装了以下的软件,主要是用来辅助编译Hikari。

另外,你的编译Hikari的环境,应该具有常用的Unix实用程序,例如:

ar — archive library builder
bzip2 — bzip2 command for distribution generation
bunzip2 — bunzip2 command for distribution checking
chmod — change permissions on a file
cat — output concatenation utility
cp — copy files
date — print the current date/time
echo — print to standard output
egrep — extended regular expression search utility
find — find files/dirs in a file system
grep — regular expression search utility
gzip — gzip command for distribution generation
gunzip — gunzip command for distribution checking
install — install directories/files
mkdir — create a directory
mv — move (rename) files
ranlib — symbol table builder for archive libraries
rm — remove (delete) files and directories
sed — stream editor for transforming output
sh — Bourne shell for make build scripts
tar — tape archive for distribution generation
testtest things in file system
unzip — unzip command for distribution checking
zip — zip command for distribution generation

macOS 快速安装

git clone -b release_70 https://github.com/HikariObfuscator/Hikari.git Hikari && mkdir Build && cd Build && cmake -G "Ninja" -DLLDB_CODESIGN_IDENTITY='' -DCMAKE_BUILD_TYPE=MinSizeRel -DLLVM_APPEND_VC_REV=on -DLLVM_CREATE_XCODE_TOOLCHAIN=on -DCMAKE_INSTALL_PREFIX=~/Library/Developer/ ../Hikari && ninja &&ninja install-xcode-toolchain && git clone https://github.com/HikariObfuscator/Resources.git ~/Hikari && rsync -a --ignore-existing /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/ ~/Library/Developer/Toolchains/Hikari.xctoolchain/ && rm ~/Library/Developer/Toolchains/Hikari.xctoolchain/ToolchainInfo.plist

ps:如果在内网环境,应该先把Hikari库和Resources先拉到本地,再执行以上的命令,注意按需修改。按照步骤走,一般编译安装阶段不会出错的。

安装常见问题

  • ninja没有安装。
  • 执行安装命令,相对路径没有改好。

Hikari应用

官方文档

改变工具链

打开xcode -> 点击左上角的Xcode菜单 -> 选择下拉菜单中的Toolchains -> 选择扩展菜单中的Hikari

ps:能在ToolChains看到Hikari选项,证明Hikari安装成功了。

用法

xcode项目配置切换到Build Settings

  • 搜索 index, 将 Enable index-while-Building Functionality 设为 No
  • 搜索 bitcode,将 Eanble BitCode 设为 No
  • 搜索 level,将 Optimization Level 设为 None[-o0]
  • 搜索 cflags,在 Other C++ Flags 后面追加混淆规则。注意,这里的混淆规则是追加,不应该覆盖默认的选项,不然会出错。Unity项目打包出来的项目一般是以C++为主。

混淆规则

官方解释

-enable-allobf   # 启用所有混淆
-enable-bcfobf   # 启用伪控制流
-enable-cffobf   # 启用控制流扁平化
-enable-splitobf # 启用基本块拆分  
-enable-subobf   # 启用指令替换
-enable-acdobf   # 启用反类转储机制  
-enable-indibran # 启用基于寄存器的相对跳转,配合其他加固可以彻底破坏IDA/Hopper的伪代码(俗称F5)
-enable-strcry   # 启用字符串加密
-enable-funcwra  # 启用函数包装器

这里的混淆规则都是随机的,默认为30%。我们也可以自定义随机概率:

-mllvm -enable-allbcf -mllvm -bcf_prob=5

提交到appStore

官方做法

  • ~/Library/Developer/Toolchains/Hikari.xctoolchain/usr 覆盖到 /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr,注意覆盖前要备份原有的usr文件夹。

  • 将archive里面plist.list文件中的DefaultToolchainOverrideInfo删除掉。若是可视化打包,要重启xcode。

常见问题

  • xcode报错信息为:"Unknown argument:'-index-store-path'"和"Cannot specify -o when generating muliple output files",这种情况一般是 Enable index-while-Building Functionality 没有设为 No
  • 出现"4 duplicate symbols for architecture armv7/arm64",一般是cflags阶段混淆配置覆盖了默认配置。
  • 若混淆打出的ipa包没什么变化,一般是 Optimization Level 没有设为 None[-o0]
  • 混淆规则只能设置一方,CFLAGS或者CPLUSPLUSFLAGS

脚本打包命令

xcodebuild OTHER_CPLUSPLUSFLAGS='$(inherited) $(OTHER_CFLAGS) ${mix_rule}' \
-project ${project_file} \
-target ${target_name} \
-configuration Debug \
-arch armv7 arm64 \
COMPILER_INDEX_STORE_ENABLE=NO \
ENABLE_BITCODE=NO \
GCC_OPTIMIZATION_LEVEL=0 \
TOOLCHAINS=Hikari

参数配置简单的解释如下:

参数 解释
OTHER_CPLUSPLUSFLAGS 混淆配置 -mllvm -enable-funcwra
COMPILER_INDEX_STORE_ENABLE 编译时是否索引 NO / YES
ENABLE_BITCODE bitcode NO / YES
GCC_OPTIMIZATION_LEVEL 编译时是否优化 NO / YES
TOOLCHAINS 选择编译器 Xcode10.1 / Hikari

混淆配置详解

1. 不加入任何混淆规则配置

在不设置混淆配置的时候,编译器Xcode 10.1和Hikari性能表现相同。

2. -enable-strcry (启用字符串加密)

  • 稍微影响打包速度,打包体积稍微增大,严重影响游戏性能,不支持数值设置。

3. -enable-bcfobf (启用伪控制流)

  • [默认值 30] 严重影响打包性能,打包体积非常明显增大,严重影响游戏性能。
  • [设置值 5] 稍微影响打包性能,打包体积有较明显增大,严重影响游戏性能。
  • [设置值 1] 几乎不影响打包性能,打包体积明显增大,严重影响游戏性能。

4. -enable-cffobf (启用控制流扁平化)

  • 几乎不影响打包性能,打包体积稍微增大,游戏直接闪退,不支持数值设置。

5. -enable-splitobf (启用基本块拆分)

  • 重影响打包性能,打包体积有较明显增大,严重影响游戏性能,不支持数值设置。

6. -enable-subobf (启用指令替换)

  • [默认值 30] 几乎不影响打包性能,几乎不影响打包体积,严重影响游戏性能。
  • [设置值 5] 几乎不影响打包性能,几乎不影响打包体积,严重影响游戏性能。
  • [设置值 1] 几乎不影响打包性能,几乎不影响打包体积,严重影响游戏性能。

7. -enable-acdobf (启用反类转储机制)

  • 几乎不影响打包性能,几乎不影响打包体积,严重影响游戏性能,不支持数值设置。

8. -enable-indibran (启用基于寄存器的相对跳转)

会报错,报错信息:

ld: symbol(s) not found for architecture armv7
clang-7: error: linker command failed with exit code 1 (use -v to see invocation)

9. -enable-funcwra (启用函数包装器)

会报错,报错信息:

ld: 61210 duplicate symbols for architecture armv7
clang-7: error: linker command failed with exit code 1 (use -v to see invocation)

10. -enable-allobf (启用所有混淆规则)

会报错,报错信息:

clang-7: error: unable to execute command: Killed: 9
clang-7: error: clang frontend command failed due to singal (use -v to see invocation)

JavaScript 数组

简介

数组是计算机编程世界里最常见的数据结构。任何一种编程语言都包含数组,只是形式上略有不同罢了。数组是编程语言中的内建类型,通常效率很高,可以满足不同需求的数据存储。

JavaScript中对数组的定义

JavaScript 中的数组是一种特殊的对象,用来表示偏移量的索引是该对象的属性,索引可能是整数。然而,这些数字索引在内部被转换为字符串类型,这是因为 JavaScript 对象中的属性名必须是字符串。数组在 JavaScript 中只是一种特殊的对象,所以效率上不如其他语言中的数组高。

JavaScript 中的数组,严格来说应该称作对象,是特殊的 JavaScript 对象,在内部被归类为数组。由于 Array 在 JavaScript 中被当作对象,因此它有许多属性和方法可以在编程时使用。

下面代码论证上述观点:

var arr = [1, 2, 3, 4, 5];
console.log(arr[1] === arr['1']); // true;
console.log(typeof arr); // object
var indexObj = { toString: function () { return 0; } }
console.log(arr[indexObj]); // 1

可见,数组在JavaScript内部也是object类型;数组根据索引获取元素的时候,会自动将下标转化为字符串。

数组创建

// 尽量使用字面量形式创建数组,因为它效率较高
var arr = [1, 2, 3, 4, 5];
// 构造函数创建数组
var arr = new Array(1, 2, 3, 4, 5);
// 通过字符串方法创建
var arr = '1,2,3,4,5'.split(',');

数组复制

var arr = [1, 2, 3, 4, 5];
var newArr = arr;
newArr[0] = 6;
console.log(arr[0]); // 6

这种方式把一个数组赋给另外一个数组时,只是为被赋值的数组增加了一个新的引用。我们称之为"浅拷贝",操作新的数组,会影响到旧的数组。对应的,我们也有”深拷贝“。

function deepCopy(src, dst) {
    for (var i = 0, len = src.length; i < len; i++) {
        dst[i] = src[i];
    }
}
var arr = [1, 2, 3, 4, 5];
var newArr = [];
deepCopy(arr, newArr);
newArr[0] = 6;
console.log(arr[0]); // 1

这个数组”深拷贝“操作并不完美,只针对数组元素中只有基本数据类型,若存在引用数据类型元素,则不适用。

数组方法

这里不展开,可以点击查看JavaScript Array 对象相关方法操作。

数组排序

定义:

arrayObject.sort(sortby); // sortby:可选。规定排序顺序。必须是函数。

这里重点讲解JavaScript中的数组排序,因为它很特别。例如我们要对一个字符串数组进行排序,可以这样写:

var arr = ['yes', 'or', 'no'];
arr.sort();
console.log(arr); // ['no', 'or', 'yes'];

看上去很简单的调用sort方法,就能轻松地实现数组的排序。那么,某一天我按字符串首字母从大到小排序呢?当然我们可以这样做:

var arr = ['yes', 'or', 'no'];
arr.sort().reverse();
console.log(arr); // ['yes', 'or', 'no'];

我能不能通过sort方法就一步到位呢?这时候,我们就要用上sort方法接受的参数sortby了。注意,sortby它代表的是一个函数的饮用,而不是一个boolean值。曾经天真的我以为sort(true) ,顺序排序;sort(false),逆序排序,然而并不是。我们看看官方对sortby的定义:

如果调用该方法时没有使用参数,将按字母顺序对数组中的元素进行排序,说得更精确点,是按照字符编码的顺序进行排序。要实现这一点,首先应把数组的元素都转换成字符串(如有必要),以便进行比较。

如果想按照其他标准进行排序,就需要提供比较函数,该函数要比较两个值,然后返回一个用于说明这两个值的相对顺序的数字。比较函数应该具有两个参数 a 和 b,其返回值如下:

若 a 小于 b,在排序后的数组中 a 应该出现在 b 之前,则返回一个小于 0 的值。
若 a 等于 b,则返回 0。
若 a 大于 b,则返回一个大于 0 的值。

相信第一次读这个定义的同学也会和当初的我一样晕,没关系的,因为有我在。首先呢,这个sortby函数,是一个回调函数,就像我们平时事件绑定,ajax回调函数一样,在数组排序过程中调用,并且注入了两个参数ab。这两个参数有什么用呢?上面罗列了三点对这两个参数的用法,用我的话形容则是:

字符串类型:

  • 返回a < b ? -1 : a === b ? 0 : 1,从小到大排序
  • 返回b < a ? -1 : a === b ? 0 : 1,从大到小排序

数字类型:

  • 返回a - b,从小到大排序
  • 返回b - a,从大到小排序

请看代码示例:

字符串类型排序:

function asc (a, b) { return a < b ? -1 : a === b ? 0 : 1; }
function desc (a, b) { return b < a ? -1 : a === b ? 0 : 1; }
var arr = ['yes', 'or', 'no'];
arr.sort(asc);
console.log(arr); // ['no', 'or', 'yes']
arr.sort(desc);
console.log(arr); // ['yes', 'or', 'no']

数字类型排序:

function asc (a, b) { return a - b; }
function desc (a, b) { return b - a; }
var arr = [3, 1, 2];
arr.sort(asc);
console.log(arr); // [1, 2, 3]
arr.sort(desc);
console.log(arr); // [3, 2, 1]

如果好奇js数组中的原生sort方法是用什么排序算法的同学,可以点击这里查看。

ps:我只负责帮大家对知识点扫盲,并不会展开一些对数组基础东西的讲解。

彻底弄懂JavaScript中的原型链

认识原型链

原型链的概念定义在 ECMAScript 中,它是 JavaScript 实现继承的主要方式,即使在 ES6 中提供了classextends 等关键字,能轻松实现类的继承,事实上这只是一种语法糖,底层还是沿用了JavaScript 中原型链的原理。

在我看来,几乎每一种面向对象的语言,例如:JavaScript、Java、C#、Python等,他们的数据类型,或者更准确来说是引用(复杂)类型,都是继承于一个最基本的类(类型)。 在 JavaScript 中所有的引用类型,都是继承于 Object 对象,除了用 Object.create(null) 这种方式创建的对象。在Java、C#、Python中Object类,同样也是其他所有类的超类。可见,许多高级编程语言的设计,都会有一个类似根类的东西。

JavaScript 继承之所以与其他面向对象语言很不一样,是因为在ES6之前,JavaScript 要实现继承,就需要各种“*操作”,而这些“*操作”大多都是基于原型链继承。所以,总体来说,很多时候,在JavaScript 中谈及到原型链,大多都会与继承扯上关系。就好比谈到“数据结构”,后面总会跟上“算法”。

想仔细深入学习继承和原型链的用法的同学,可以翻看红宝书《JavaScript高级程序设计(第三版)》中的第六章,第三小节的介绍,非常详细,网上很多博客和教程,都是基于它来展开或者补充的。没有这本书的同学,可以点击这里

我们进入正题,请先看以下的例子:

示例:

// ES5利用函数声明类
function Person (name, age) {
    this.name = name;
    this.age = age;
}
// 实例化
var person = new Person("Checkson", 23);
// 判断实例类型
console.log(person instanceof Person);
console.log(person instanceof Object);

输出结果:

true
true

这里为什么都是输出 true 呢?第一个输出语句判断的是 person 是否是 Person 的实例,那么很简单,因为 person 是通过 Person new 出来的,所以结果返回是true;第二个输出语句是判断person 是否是 Object 的实例,这里我们并没有看到 Person 类有显示声明是继承于 Object 类的,但是最后输出的结果是 true,证明,我之前提到过的:默认情况下,基类Object是所有类的超类。那么,在 JavaScript 中,以上例子中的 Person 类是怎么与 Object 类联系在一起的呢?请看下图:

default

从图中可以看到:

  • Person 函数(类)有一个名为 prototype 的属性,指向了Person的原型;而实例person有一个名为 __proto__ 的属性,也指向了 Person 的原型 prototype。所以我们可以得出一条公式:Person.prototype = person.__proto__

2

  • Person.prototypeperson.__proto__ 都是指向同一个对象,我们称它为“原型”。这个原型对象有两个属性,分别是 constructor__proto__constructor 顾名思义,是指向构造函数的属性,那么我们又可以得出一个公式:Person.prototype.constructor = person.__proto__.constructor = Person__proto__ 就是这里提到的原型对象的原型,这个新的原型同样具有constructor属性,但没有 __proto__ 属性,因为它已经指向了基类Object了。

所以说,原型链就是由多个原型对象组成的链式结构。因为我们把这种特殊的对象称为“原型”,他们组成的链就叫“原型链”,其实,我们可以笼统理解为它也是一个“对象链”。

每个函数(类)都有 prototype 属性,除了 Function.proptype.bind(),该属性指向原型;每个对象都有 __proto__ 属性,除了 Object.create(null),该属性也指向原型。

原型链用途

相信读过jquerybootstrapbootstrap-tableselect2或者其他早期知名的库的同学,都会发现,他们的组织插件的写法一般都是一个构造函数和一些定义在这个构造函数原型上的函数,例如:

function JQuery (el) {
    this.el = el;
    ...
}
JQuery.prototype.addClass (className) {
     // pass
}
JQuery.prototype.removeClass (className) {
     // pass
}
...

这样写的原因是:每实例化 JQuery 类的时候,私有属性 this.el 会在内存中开辟一个新的内存空间,而在定义在原型上的函数,则只会开辟一份内存空间,不会随着实例化对象增多而开辟更多的内存空间,这样就大大节省了内存,提高程序整体的性能,其作用就类似C#、Java中的静态方法。

原型链另外一种用途就是我们之前提到的继承了。我们先看一下ES5中最简单的继承方式:

function Person (sex) {
    this.sex = sex;
}
function Male (name, age) {
    this.name = name;
    this.age = age;
}
// 将Male的原型指向实例化后的Person对象
Male.prototype = new Person('male');
// 将构造函数constructor指向Male
Male.prototype.constructor = Male;
// 实例化Male
var male = new Male('Checkson', 23);
// 输出male的性别
console.log(male.sex);  // male

输出 maleMale.prototype 结果如下图:

3

这里我们会发现,male对象,并不存在sex这个属性,但是能输出male字符串,是因为这个属性存在于原型上。那么对象本身不存在的属性,JavaScript会自动在原型中找的?这就是JavaScript原型的特性了。假如对象本身不存在某个属性,JavaScript就会自动沿着对象__proto__这个属性,一级一级往上找,直到在某个原型对象上找到或者找到Object对象都没有的话,就停止搜索了。利用原型链这个特性,我们可以很好地在JavaScript中实现继承。

细心的同学还会发现,Male.prototype指向的是Person的实例,而不是Person本身。这里的用意是:

  • 保持原型链的完整性。上面提到,每个对象都有__proto__属性,而原型链依赖这个属性连接起来,将函数类型实例化为对象类型是为了获得这个属性。
  • 实例化后的对象,才会拥有该函数(类)中定义的属性和方法。

最后,我们回应上文,究竟ES6中的继承,也是不是原型链继承中的一个语法糖呢?我们先看例子:

class Person {
    constructor (sex) {
        this.sex = sex;
    }
    getSex () {
        return this.sex;
    }
}
class Male extends Person {
    constructor  (name, age) {
        super('male');
        this.name = name;
        this.age = age;
    }
    getName () {
        return this.name;
    }
    getAge () {
        return this.age;
    }
}
// 实例化Male
const male = new Male('Checkson', 23);
// 输出sex属性值
console.log(male.sex); // male

输出maleMale.prototype结果如下图:

4

从输出结果可以看出,除了父类属性归类在当前对象上,其他函数定义都是放在原型对象上,也按照ES5原型链的结构来组织继承关系。

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.