问题全部来自《剑指Offer》,顺序一致。

单例模式

1.使用字面量声明对象

1
2
3
4
5
6
7
const Singleton = {
prop1: "prop1"
prop2: "prop2"
method1() {
console.log("I am a method.");
}
}

或者通过闭包封装自己的私有变量和方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const Singleton = function() {
let privateProp = "You can't see me";

function privateMethod() {
console.log("You can't see me either.");
}

return {
publicProp: `${privateProp}!`,
publicMethod() {
return privateMethod();
}
}
}

2.利用闭包

本质上和上面的方法类似

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
const Singleton = function() {
let instance;

function init() {
return {
prop: "prop",
method() {
console.log("I am a method.");
}
};
}

return {
getInstance() {
// JS单线程,不考虑锁的问题
if (!instance) {
instance = init();
}
return instance;
}
}
}

还有一个酷炫的变种,不过是上面的构造函数写法

1
2
3
4
5
6
7
8
9
10
11
12
const Singleton = function() {
const instance = this;

this.prop = "prop";
this.method = function() {
console.log("I am a method.");
}

Singleton = function() {
return instance;
};
}

3.借助构造函数和JS中函数也是对象的特点

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const Singleton = function() {
// 这里不取名为instance也可以
// 显式返回this
if (typeof Singleton.instance === "object") {
return Singleton.instance;
}

// 成员和方法
this.prop = "prop";
this.method = function() {
console.log("I am a method.");
}

Singleton.instance = this;

//隐式返回this
}

参考

找出数组中重复的数字

Set,使用ES6内建的数据结构。

1
2
3
4
5
6
7
8
9
const mySet = new Set();
arr.forEach((val, index) => {
if (mySet.has(val)) {
console.log(val);
return;
} else {
mySet.add(val);
}
});

ObjectArray(空间复杂度O(n)),换汤不换药,略。

传统方法,时间复杂度O(n),空间复杂度O(1)。交换当前元素和当前索引对应的元素,直到两者相等。(请参考原书中的前提,数字都在0~n-1之间)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function findDuplicate(arr) {
// 边界检测
if (!arr || arr.length === 0) {
return false;
}
for (let i = 0, len = arr.length; i < len; i++) {
// 循环交换元素直到arr[i] == i
while (arr[i] != i) {
if (arr[i] === arr[arr[i]]) {
return arr[i];
}
// 交换
[arr[i], arr[arr[i]]] = [arr[arr[i]], arr[i]];
}
}
}

传统方法,且不修改数组,利用折半查找,递归寻找重复的元素。时间复杂度O(nlogn),空间复杂度O(1)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
function findDuplicate(arr) {
// 边界检测
if (!arr || arr.length === 0) {
return false;
}

function countRange (arr, start, end) {
if (start > end) {
return false;
} else if (start === end) {
return start;
}
const split = (start + end) / 2;
const count = arr.reduce((last, val) => {
return last + +(val >= start && val <= split);
}, 0);
if (count > split - start + 1) {
countRange(arr, start, split);
} else {
countRange(arr, split + 1, end);
}
}

return countRange(arr, 0, arr.length);
}

二维数组中的查找

前提:数组的每一行和每一列都递增

贪婪算法,从右上角开始。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
function find(arr, val) {
if (!arr || !arr.length) {
return false;
}
let [rows, columns] = [arr.length, arr[0].length];

if (!rows || !columns) {
return false;
}
let [x, y] = [columns-1, 0]
while(x > 0 && y < rows) {
if (arr[y][x] === val) {
return true;
} else if (arr[y][x] > val) {
x--;
} else {
y++;
}
}
return false;
}

替换字符串中的空格

JavaScript中很好实现,可以直接使用库函数。

1
2
3
function replace(str) {
return str.replace(" ", "%20");
}

使用传统方法,思路是先从前到后找到空格数,扩展出足够空间。再使用两个指针,从后到前,P1指向旧字符串的末尾,P2指向新字符串的末尾。碰到空格时,P2插入”%20”。碰到其他字符时,挨个复制即可。

这种双指针从后向前的思维还可以用在两有序数组合并。从尾到头比较A1和A2数字。

链表设计

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
// 单向链表
let LinkedList2 = (function() {
class Node {
constructor(element) {
this.element = element;
this.next = null;
}
}

class LinkedList {
constructor() {
this.length = 0;
this.head = null;
}

append(element) {
let node = new Node(element);
if (this.head === null) {
this.head = node;
} else {
let curr = this.head;
while(curr.next) {
curr = curr.next;
}
curr.next = node;
}
this.length++;
}

insert(pos, element) {
if (pos < 0 || pos > this.length) {
return false;
}
let curr = this.head,
node = new Node(element);
while(--pos >= 0) {
curr = curr.next;
}
node.next = curr.next;
curr.next = node;
this.length++;
}

remove(pos) {
if (pos < 0 || pos > this.length) {
return false;
}
let curr = this.head;
prev;
while(--pos >= 0) {
prev = curr;
curr = curr.next;
}
if(isUndefined(prev)) {
this.head = null;
} else {
prev.next = curr.next;
}
this.length--;
}

indexOf(element) {
let curr = this.head,
index = 0;
while (curr) {
if (element === curr.element) {
return index;
}
index++;
curr = curr.next;
}
return -1;
}
}

})();

// 双向链表
// 略

从尾到头打印链表

前提:单向链表。使用递归

1
2
3
4
5
6
7
function recursivePrint(node) {
if (node && node.next) {
console.log(recursivePrint(node.next));
} else {
console.log(node.element);
}
}

重建二叉树

前提:根据前序和中序遍历的结果。

1
2
3
4
5
6
7
class BinaryTreeNode {
constructor(element, left, right) {
this.element = element;
this.left = left;
this.right = right;
}
}

通过前序遍历找到根节点,结合中序遍历找到左右子树,之后递归构建左右子树。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function constructTree(preorder, inorder) {
if (preorder.length === 0) {
return null;
}

function construct(preorder, inorder) {
// 找到根节点元素
let root = preorder[0];
// 确定左右子树
let index = inorder.indexOf(root),
left = (index === 0 ? null : construct(preorder.slice(0, index), inorder.slice(0, index)));
right = (index === inorder.length-1 ? null : construct(preorder.slice(index+1), inorder.slice(index+1)));

return new BinaryTreeNode(root, left, right);
}

return construct(preorder, inorder);
}

二叉树的下一个节点

前提:树的每个节点除了有两个子节点的指针,还有一个指向父节点的指针。寻找中序遍历的下一个节点。

1
2
3
4
5
6
7
8
class BinaryTreeNode {
constructor(element, left, right, parent) {
this.element = element;
this.left = left;
this.right = right;
this.parent = parent;
}
}

二叉树中的节点和周围节点可能有四种关系(画图出来更直观)

  1. 左子树的父节点
  2. 右子树的父节点
  3. 父节点的左子树
  4. 父节点的右子树

从而有下面的流程:

  1. 关系2存在时,返回右子树的最左节点,否则到步骤2
  2. 关系3存在时,返回父节点,否则到步骤3
  3. 关系4存在时,向上回溯,对父节点使用步骤2,否则到步骤4
  4. (节点是根节点)返回null
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
function findNextNode(node) {
let curr;
if (node.right) {
curr = node.right;
while(curr = curr.left) {
;
}
return curr;
} else if (node.parent) {
if (node.parent.left === node) {
return node.parent;
} else if (node.parent.right === node) {
curr = node.parent;
while (curr.parent && curr.parent.right === curr) {
curr = node.parent;
}
if (curr.parent) {
return curr.parent;
}
}
} else {
return null;
}
}

用两个栈实现队列

前提,实现append和delete功能,完成在队尾插入和队首删除。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Stack {
constructor() {
this.stack = []
}

get size() {
return this.stack.length;
}

push(element) {
return this.stack.push(element);
}

pop() {
return this.stack.pop();
}

}

思路,栈1只允许正序存放,栈2只允许倒序存放,由于栈2的内容始终较老,因此插入总发生在栈1。删除总发生在栈2(栈2为空时除外)。JS中没有泛型的概念,所以无需考虑栈中的数据类型。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
class Queue {
constructor() {
this.stack = new Stack();
this.inverseStack = new Stack();
}

append(element) {
this.stack.push(element);
return this.stack.size + this.inverseStack.size;
}

delete() {
if (!this.inverseStack.size) {
while (!this.stack.size) {
this.inverseStack.push(this.stack.pop());
}
}

// 判断是否为空
if (!this.inverseStack.size) {
return this.inverseStack.pop();
}

console.warn("Empty queue!");
return false;
}
}

用两个队列实现栈

前提同上。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Queue {
constructor() {
this.queue = []
}

get size() {
return this.stack.length;
}

in(element) {
return this.stack.push(element);
}

out() {
return this.stack.shift();
}

}

思路和上面类似,但不大一样。每次元素出队时,所有元素一定会转移到另一个队列中。因此,插入和删除一定发生在有元素的队列。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
class Stack {
constructor() {
this.queue = new Queue();
this.backupQueue = new Queue();
}

append(element) {
if (queue.size) {
queue.in(element);
return queue.size;
} else {
backupQueue.in(element);
return backupQueue.size;
}
}

delete() {
if (queue.size) {
let element;
while(queue.size > 1) {
element = queue.out();
backupQueue.in(element);
}
return queue.out();
} else if(backQueue.size > 1) {
let element;
while(backupQueue.size) {
element = backupQueue.out();
queue.in(element);
}
return backupQueue.out();
}
console.warn("Empty stack!");
}
}

斐波那契数列

使用递归的代码好写,但是效率低;使用循环更好。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function fibonacci(n) {
if (n < 2) {
return n;
}

let a = 0,
b = 1,
res = 1;
for (let i = 2; i <= n; i++) {
[res, a, b] = [a + b, b, res];
}

return res;
}

变种还有“青蛙跳台阶”等类最优子问题的问题。

旋转数组的最小数字

前提,将数组开头的部分元素搬运到数组的末尾,寻找最小的元素。

使用二分查找,右半边递增时,最小元素在左半边。左半边递增时,最小元素在右半边。注意下面两种情况:

  • 数组未做旋转
  • 旋转后,头尾元素相等
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
function findMin(arr) {
if (!arr || !.arr.length) {
return;
}

let lindex = 0,
rindex = arr.length - 1,
res = lindex;

while (arr[lindex] >= arr[rindex]) {
if (rindex - lindex === 1) {
res = rindex;
}

res = (lindex + rindex) / 2;

// 特殊情况
if (arr[res] == arr[lindex] && arr[res] == arr[rindex]) {
return arr.reduce((min, val) => {
return Math.min(min, val);
}, arr[0]);
}

if (arr[res] >= arr[lindex]) {
lindex = res;
} else if (arr[res] <= arr[rindex]) {
rindex = res;
}
}
return arr[res];
}

回溯法

通常适合用递归法实现,到达某个节点时,尝试所有可能,并在满足条件时递归到下一节点。

矩阵中的路径

前提,判断矩阵中有无一条不交叉路径能够返回指定字符串

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
function hasPath(arr, str) {

if (!arr || !arr.length || !str) {
return false;
}

// 使用数组记录历史数据,防止交叉
let occupied = arr.map(() => {
return arr[0].map(() => {
return false;
});
});

let pathMath = function(i, j, str) {
if (!str) {
return true;
}

if (i < 0 || i >= arr.length || j < 0 || i >= arr[0].length) {
return false;
}

if (str.substr(0, 1) == arr[i][j]) {
occupied[i][j] = true;
let newStr = str.substr(1),
res = pathMath(i-1, j, newStr) || pathMath(i+1, j, newStr) || pathMath(i, j-1, newStr) || pathMath(i, j+1, newStr);

// 回溯
if (!res) {
occupied[i][j] = false;
}
}

return res;
}

arr.foreach((val, i) => {
val.foreach((val, j) => {
if (pathMatch(i, j, str)) {
return true;
}
});
});

return false;
}

机器人的运动范围

前提,m行n列的矩阵,机器人从(0,0)出发不能进入数位和大于k的格子,求能到的格子个数。

思路同上甚至更简单。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
function gridCount(rowCount, colCount, limit) {
if (limit < 0 || rowCount <= 0 || colCount <= 0) {
return 0;
}

// 使用数组记录历史数据,减少冗余查询
let walked = [];

let count = function(row, col) {
let cnt = 0;

if (check(row, col)) {
// 不必还原,减少重复查询
walked[row * rowCount + col] = true;
cnt = 1 + count(row-1, col) + count(row+1, col) + count(row, col-1) + count(row, col+1);
}
return cnt;
},
check = function () {
return row >= 0 && col >= 0 && !walked[row * rowCount + col] && digitSum(row) + digitSum(col) <= limit;
},
digitSum = function(num) {
let sum = 0;
while(num > 0) {
sum += num % 10;
num /= 10;
}
return sum;
};

return count(row, col)
}

剪绳子

前提,绳长为整数,切成若干段,使得绳长乘积最大。

类似于《算法导论》里的裁切钢管。首先可以用动态规划

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function cutRope(len) {
if (len < 4) {
return len - 2;
}

let cut = [0, 1, 2];
for (let i = 4; i <= len; i++) {
let max = 0;
for (let j = 1; j <= i / 2; j++) {
let mul = cut[j-1] * cut[i-j-1];
if (max < mul) {
max = mul;
}
}
cut.push(max);
}
return cut[len-1];
}

经过数学推导,n>=5时,2(n-2)>n3(n-3)>n2(n-2)>=3(n-3),因此有最优子问题的解法。根据贪婪算法,应该尽量多切长度为3的绳子段。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function cutRope(len) {
if (len < 4) {
return len - 2;
}

// 多切长度为3的绳子段
let cntOf3 = len / 3,
cntOf2 = 1,
remain = len % 3;
// 长度为4时,应该切成两个2
if (remain === 1) {
cntOf3--;
cntOf2 = 2;
}

return Math.pow(3, cntOf3) * cntOf2
}

位运算

经常用于奇技淫巧。在C风格的解法中常用。JS中应该不常出现。

二进制中1的数目

通过移位操作,需要注意的是,对入参移位容易出现死循环,可以对1进行移位并比较。

1
2
3
4
5
6
7
8
9
10
11
12
13
function numOf1(n) {
let cnt = 0,
flag = 1;

while(flag) {
if (n & flag) {
cnt++;
}
flag <<= 1;
}

return cnt;
}

二进制整数减1相当于右侧0值变成1,最右侧1变成0,此时和原数做按位与,可以消掉最右侧0。利用这个特点可以进一步提高效率。这种思路可以用来解决很多二进制问题。

1
2
3
4
5
6
7
8
9
10
function numOf1(n) {
let cnt = 0;

while(n) {
++cnt;
n = (n-1) & n;
}

return cnt;
}

数值的整数次方

前提,不得使用库函数,需要考虑大数问题

用类似二分法的思想,由2到4,由4到8,由8到16。时间复杂度O(logn)。同时注意边界情况。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
function pow(base, exp) {
let isNeg = exp > 0,
res = 1;

if (exp == 0) {
return 1;
}

if (base == 0) {
return 0;
}

function unsignedPow(base, exp) {
if (exp === 0) {
return 1;
}
if (exp === 1) {
return base;
}

let res = unsigned(base, exp >> 1);
res *= res;
if (exp & 0x1 === 1) {
res *= base;
}
return res;
}

res = unsignedPow(base, Math.abs(exp));

if (isNeg) {
res = 1 / res;
}

return res;
}

按顺序打印从0到最大的n位数字

大数问题。自定义数据结构实现整数+1操作,或者通过递归for循环依次打印每一位数字,像下面这样。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
function printNums(n) {
function print(str, n) {
if (1 === n) {
for (let i = 0; i < 10; i++) {
console.log(str + i);
}
} else {
n--;
for (let i = 0; i < 10; i++) {
if (0 === i) {
print(str, n);
} else {
print(str + i, n);
}
}
}
}

if (n < 1) {
return;
}

print('', n);
}

删除链表节点

1
2
3
4
5
6
class LinkedListNode {
constructor(value, next) {
this.value = value;
this.next = next
}
}

O(1)时间删除单向链表中某节点

通常思路,需要从头结点循环找到节点的上一个节点,将它的next属性设置删除节点的next。但是这么做是O(n)的复杂度。更好的方法是,借尸还魂,覆盖当前节点的next节点,然后重设next节点跳过下一节点。

(因为JS中没有指针一说,这种模拟不能完全还原原题的问题)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function removeNode(head, node) {
if (!head || !node) {
return;
}

if (!node.next) { // 不是尾节点
node.next = node.next.next;
node.value = node.value;
} else if (node == head) { // 链表只有一个节点
head = null;
} else { // 是尾节点,只能循环
let n = head;
while (n.next !== node) {
n = n.next;
}
n.next = null;
}
}

表示数值的字符串

考察思维的全面性。包括正数负数、小数、科学技术法都要考虑。格式遵循[+|-]?(\d+)(\.\d+)?([e|E][+|-]?\d+)?

1
2
3
4
5
6
// 正则表达式
/\d/.test(str);

// 类型转换
!isNaN(+str);

JS下解决问题并不如C++自然。下面是C++风格的解法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
bool judgeUnsignedInt(const char ** str) {
const char * start = *str;
while(**str != \0 && **str >= '0' && **str <= '9') {
++(*str);
}

return *str > start;
}

bool judgeInt(const char ** str) {
if (**str === '+' || **str === '-') {
++(*str);
}
return judgeUnisignedInt(str);
}

bool isNumber(const char * str) {
if (str == null) {
return false;
}

// 先扫描整数部分
bool res = judgeInt(&str);

if (*str == '.') {
++str;

// 扫描小数部分
res = judgeUnsignedInt(&str) || res;
}

if (*str == 'e' || *str == 'E') {
++str;

// 扫描指数部分
res = judgeInt(&str) && res;
}

return res && *str == '\0';
}

调整整数数组中奇数和偶数的位置

前提,使得奇数都位于前边,偶数都位于后边。

考虑拓展性,用一个函数作为衡量指标,判断元素应该在前还是在后。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
function adjustArr(arr, standard) {
if (!arr || !arr.length || arr.length < 2) {
return arr;
}

let start = 0;
let end = arr.length - 1;
while (start < end) {
while (start < end && !standard(arr[start])) {
start++;
}

while (start < end && standard(arr[end])) {
end--;
}

if (start < end) {
[arr[start], arr[end]] = [arr[end], arr[start]];
}
}
}

// 在后面的标准
function standard (num) {
return num / 2 == 0;
}

鲁棒性

链表的倒数第K的节点

1
2
3
4
5
6
7
8
9
10
11
class LinkedList {
constructor(head) {
this.head = head;
}
}

class Node {
constructor(next) {
this.next = next;
}
}

考虑鲁棒性:

  • 空数组
  • k超过数组长度
  • k=0
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
function findLastK(head, k) {
if (!head || k === 0) {
return null;
}

let flag = head;
while (--k > 0) {
if (head.next) {
head = head.next;
} else {
return null
}
}

let res = head;
while(flag = flag.next) {
res = res.next;
}

return res;
}
当一个指针不足以解决链表的问题时,通常两个指针就可以解决问题

链表中环的入口节点

思路:

  1. 链表中有环存在时,走得快的指针将会追上走得慢的指针。
  2. 确定环的长度k后,让两个指针同速,一个领先k位出发,相遇的位置即是入口

考虑鲁棒性:

  • 无环时
  • 寻找相遇点的时候注意next属性是否存在
  • 头结点为环入口时
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
function entryOfLoop(list) {
if (!list || !list.length || !list.length < 2) {
return null;
}

// 找到相遇位置
let slow, fast, meet;
slow = fast = list.head;

while (!!fast && !!slow) {
if (fast === slow) {
meet = fast;
}
slow = slow.next;
fast = fast.next;
if (!!fast) {
fast = fast.next;
}
}

// 无环存在时跳出
if (!!meet) {
return null;
}

// 确定环的长度
let length = 1;
let tmpNode = meet.next;
while (tmpNode !== meet) {
tmpNode = tmpNode.next;
length++;
}

// 先移动一个指针
let first = list.head;
while (length > 0) {
first = first.next;
length--;
}

// 再移动第二个指针
let next = list.head
while (next !== first) {
first = first.next;
next = next.next;
}

return first;
}

反转链表

前提:反转链表并输出反转后的头节点。

考虑鲁棒性:

  • 边界情况,如空链表或长度为1的链表
  • 反转过程中链表断裂
1
2
3
4
5
6
7
8
9
10
11
12
13
function reverseList(list) {
let node = list.head;
let prev = null;

while(!!node) {
if (!node.next) {
return node;
}
node.next = prev;
prev = node;
node = node.next;
}
}

合并两个排序的链表

类似归并排序。

考虑鲁棒性:

  • 空链表
  • 链表长度为1
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
function merge(list1, list2) {
if (!list1) {
return list2;
} else if (!list2) {
return list1;
}

let node = new Node();
let list = new List(node);
let h1 = list1.head;
let h2 = list2.head;

for (!!h1 || !!h2) {
let next;
if (!!h1 && !h2 || h1.value < h2.value) {
next = h1.value;
node = node.next;
h1 = h1.next;
} else {
next = h2.value;
h2 = h2.next;
}

node.next = new Node(next);
node = node.next;
}

list.head = list.head.next;
return list;
}

树的子结构

前提:判断树A中是否能找到和树B结构一样的子树。

使用递归思路更简单。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
function hasSubTree(heap, needle) {
function match(root1, root2) {
if (!root2) {
return true;
}

if (!root1) {
return false;
}

return match(root1.left, root2.left) && match(root1.right, root2.right);
}

let root1 = heap.root;
let root2 = needle.root;

let res = false;
if (!root1 && !root2) {
if (root1.value === root2.value) {
res = match(root1, root2);
}

if (!res) {
res = hasSubTree(root1.left, root2);
}

if (!res) {
res = hasSubTree(root1.right, root2);
}
}

return res;
}

形象化

二叉树的镜像

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function mirrorBinaryTree(root) {
if (!root) {
return;
}
// 跳过叶子节点
if (!root.left && !root.right) {
return;
}

[root.left, root.right] = [root.right, root.left];

if (!!root.left) {
mirrotBinaryTree(root.left);
}

if (!!root.right) {
mirrotBinaryTree(root.right);
}
}

对称的二叉树

即前序和对称遍历结果一致。和上面类似。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function isSymmetrical(root) {
function judge(root1, root2) {
if (root1 === null && root2 === null) {
return true;
}
if (root1 === null || root2 === null) {
return false;
}
if (root1.value !== root2.value2) {
return false;
}

return judge(root1.left, root2.right) && judge(root1.right, root2.left);
}

return judge(root, root);
}

顺时针打印矩阵

  • 打印一行“删去”一行,打印一列“删去”一列。
  • 最后一圈先向右再向下再向上再向左,根据顺序共有4种可能,通过if语句区分
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
function printMatrix(matrix) {
if (!matrix || !matrix.length || !matrix[0].length) {
return;
}
let top = 0;
let left = 0;
let bottom = matrix.length - 1;
let right = (matrix[0].length ? matrix[0].length - 1 : 0);

while (left <= right && top <= bottom) {
// 向右
for (let i = left; i <= right; i++) {
console.log(matrix[top][i] + ' ');
}
top++;

// 向下
if (top <= bottom) {
for (let i = top; i <= bottom; i++) {
console.log(matrix[i][right] + ' ');
}
right--;
} else {
return;
}

// 向左
if (left <= right) {
for (let i = right; i >= left; i--) {
console.log(matrix[bottom][i] + ' ');
}
bottom--;
} else {
return;
}

// 向上
if (top <= bottom) {
for (let i = bottom; i >= top; i--) {
console.log(matrix[i][left] + ' ');
}
left++;
} else {
return;
}
}
}

剑指offer上的思路是利用每圈的起始x和y坐标相等,判断循环终止条件,再根据条件决定一圈打印几行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
function printMatrix(matrix) {
if (!matrix || !matrix.length || !matrix[0].length) {
return;
}

function print() {
let width = column - start - 1;
let height = rows - start - 1;

for (let i = start; i <= width; i++) {
console.log(matrix[start][i]);
}

if (start < height) {
for (let i = 0; i <= height; i++) {
console.log(matrix[i][width]);
}
}

if (start < width && start < height) {
for (let i = width-1; i >= start; i--) {
console.log(matrix[height][i]);
}
}

if (start < width && start < height - 1) {
for (let i = height-1; i < start+1; i--) {
console.log(matrix[i][start]);
}
}
}

let start = 0;
let rows = matrix.length;
let column = matrix[0].length;

while (column > 2 * start && rows > 2 * start) {
print();
++start;
}
}

包含min函数的栈

前提,设计栈的数据结构,使min、push和pop的时间复杂度都是O(1)。

注意:若使用变量保存最小元素,那么在最小元素出栈后会找不到次小元素。可见只使用一个变量是不够的,为配合原始栈的结构,需要有一个辅助栈记录最小元素的位置。(画图可以很容易理解)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
class Stack {
constructor() {
this.stack = [];
this.minStack = [];
}

get min() {
let len = this.minStack.length;
return (len ? this.minStack[len-1] : Infinity);
}

push(element) {
this.stack.push(element);
this.minStack.push(Math.min(this.min, element));
}

pop() {
this.stack.pop();
this.minStack.pop();
}

top() {
return (this.stack.length ? this.stack[this.stack.length-1] : null);
}
}

栈的压入、弹出序列

前提:给定栈的压入顺序,判断某个出栈顺序是否可能

通过举例具象化思考。如入栈顺序为[1, 2, 3, 4, 5],[4, 3, 5, 2, 1]就是一个可能的例子。首先,4是第一个出栈元素,则[1,2,3]此时已经入栈,4出栈后,3位栈顶,出栈后,5不在栈内,将5入栈。剩下的5,2,1同理。因此,可以发现下面规律。

  1. 第一个出栈元素前的入栈序列此时正在栈内
  2. 之后所有的出栈元素,先在栈顶寻找,若有,则出栈;若没有,在栈外元素中寻找
  3. 若找到,将其入栈,若找不到,则返回false
  4. 如此循环直到栈空
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
function isPopOrder(push, pop) {
if (push.length !== pop.length) {
return false;
}
if (!push.length) {
return true;
}

// 寻找当前栈内元素
let first = pop[0];
let index = push.indexOf(first);
let stack = [];

if (index === -1) {
return false;
}

for (let i = 0; i <= index-1; i++) {
stack.push(push[i]);
}

let rest = push.slice(index);

for (let i = 2, len = pop.length; i < len; i++) {
let value = pop[i];
if (!push.length || push[push.length-1] !== value) {
let index = rest.indexOf(value);
if (index === -1) {
return false;
}
push.push(value);
rest.splice(index, 1);
} else {
push.pop();
}
}

return true;
}

从上到下打印二叉树

使用队列保证先后顺序。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
class Queue {
constructor() {
this.queue = []
}

get length() {
return this.queue.length;
}

in(element) {
this.queue.push(element);
return this.queue.length;
}

out() {
return this.queue.shift();
}
}

function printTreeByTier(root) {
if (!root) {
return;
}

let q = new Queue();
q.in(root);

while(q.length) {
let node = q.out();
console.log(node.value);
if (node.left) {
q.in(node.left);
}
if (node.right) {
q.in(node.right);
}
}
}

进阶:分行从上到下打印二叉树

和上面类似,区别在入队时需要携带层数信息。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
class Queue {
constructor() {
this.queue = []
}

get length() {
return this.queue.length;
}

in(element) {
this.queue.push(element);
return this.queue.length;
}

out() {
return this.queue.shift();
}
}

function printTreeByTier(root) {
if (!root) {
return;
}

let tier = 0;
let q = new Queue();
q.in({
root,
tier,
});

while (q.length) {
let node = q.out();

if (node.tier !== tier) {
tier = node.tier;
console.log("换行");
}

if (q.root.left) {
q.in({
root: q.root.left,
tier: tier+1,
})
}

if (q.root.right) {
q.in({
root: q.root.right,
tier: tier+1,
});
}
}
}

进阶2:之字形打印二叉树

前提:第一行从左到右打印,第二行从右到左打印,以此类推。

因为每层的顺序都相反,很适合栈存储。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
// 栈的数据结构实现略
// ...

function printTreeByZ(root) {
if (!root) {
return;
}

let stack1 = new Stack();
let stack2 = new Stack();
// 当前在哪个栈内
let curr = 1;

stack1.push(root);

while (!stack1.empty() || !stack2.empty()) {
if (curr == 1) {
let node = stack1.pop();
console.log(node.value);

if (node.left) {
stack2.push(node.left);
}

if (node.right) {
stack2.push(node.right);
}

if (stack1.empty()) {
curr = 2;
console.log("换行");
}
} else {
let node = stack2.pop();
console.log(node.value);

if (node.right) {
stack1.push(node.right);
}

if (node.left) {
stack1.push(node.left);
}

if (stack2.emtpy()) {
curr = 1;
console.log("换行");
}
}
}
}

二叉搜索树的后序遍历序列

前提:判断整数序列是否是某个二叉搜索树的后序遍历序列,并假设所有数字都不相同

利用二叉搜索树的特点,结合后序遍历时尾元素为根节点,递归判断。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
function verifySeqOfBST(seq) {
if (!seq || seq.length <= 0) {
return false;
}

let len = seq.length;
let root = seq[len-1];
// 找到右子树的起点
let rightIndex = seq.findIndex((element) => {
return element > root;
});

// 右子树的节点需要都大于左节点
if (rightIndex == -1 || seq.slice(rightIndex).every((element)=>{ return element > root; })) {
return false;
}

// 递归判断左右子树
let left = rightIndex <= 0 || verifySeqOfBST(seq.slice(0, rightIndex));
let right = rightIndex == -1 || verifySeqOfBST(seq.slice(rightIndex));

return left && right;
}

二叉树中和为某值的路径

前提:找到所有满足要求的路径

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
function findPaths(root, target) {
let path = [];
let sum = 0;
let node = root;

// 利用闭包共享变量
let find = () => {
path.push(node);
sum += node.value;

if (sum === target) {
console.log(path)
}

if (node.left) {
node = node.left;
find();
}

if (node.right) {
node = node.right;
find();
}

// 不要忘了弹出栈顶元素
path.pop();
}

find();
}

分解

复杂链表的复制

前提:复杂链表中每个节点除了一个指向下个节点的指针外,还有一个指向任意节点的指针

1
2
3
4
5
6
7
class ComplexLinkedListNode {
constructor({value, next, extra}) {
this.value = value;
this.next = next;
this.extra = extra;
}
}

困难在extra属性的复制,在O(n)的时间复杂度,O(1)的空间复杂度的要求下,问题可以分解成3步

  1. 在每个原始节点后接上复制节点
  2. 原样复制extra属性
  3. 将复制的链表拆出来
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
function copyLinkedList(head) {
if (!head) {
return null;
}

let node = head;

// Step1: 在每个节点后添加一个复制节点
while (node) {
node.next = new ComplexLinkedListNode()({
value: node.value,
next: node.next
});
node = node.next;
}

// Step2: 复制每个节点的extra属性
node = head;
while (node) {
// 这一步大大减少了时间复杂度
node.next.extra = node.extra.next;
node = node.next.next;
}

// Step3: 拆开两条链
node = head
let newNode = new ComplexLinkedListNode();
while (node) {
newNode.next = node.next;
node.next = node.next.next;
node = node.next;
newNode = newNode.next;
}

return newNode.next;
}

二叉搜索树和双向链表

前提:将一个二叉搜索树转换成一个排序的双向链表,不允许新建节点。

一般涉及到树结构的问题,使用递归都要更直观方便。本题中,可以把问题拆解成,将根节点和已组装好的左右双向链表组装。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
function transBSTToBilink(root) {
if (!root) {
return;
}

let split = (root, type) => {
let node = new Node(root.value);
let left = node;
let right = node;

if (root.left) {
left = split(root.left, 'left');
left.next = node;
node.next = left;
}
if (root.right) {
right = split(root.right, 'right')
node.next = right;
right.next = node;
}

// 通过type区分当前是左子树还是右子树,从而返回最大点或最小点
return (type === 'left' ? right : left);

}

split(root);
}

序列化二叉树

要求:实习序列化和反序列化函数

通常情况需要两种遍历序列才能还原二叉树,这是因为二叉树非完全。对空缺的节点使用特殊字符,即可消除二义性,使用单一遍历序列还原二叉树。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
class BinaryTree {
// ...
}

BinaryTree.serialize = (root) => {
let str = [];
let serialize = (root) => {
if (!root) {
str.push('$');
return;
}
str.push(root.value);
serialize(root.left);
serialize(root.right);
});
serialize(root);
return str.join('');
}

BinaryTree.parse = (str) => {
if (!str) {
return null;
}
let i = 0;
let len = str.length;
let parse = (str) => {
if (str[i] === '$') {
return null;
}
let root = new Node(+str[i]);
// 最后一个节点必是叶节点
++i, root.left = parse(str);
++i, root.right = parse(str);
return root;
}
return parse(str);
}

字符串的排列

前提:不考虑重复字符的问题
要求:输入一个字符串,输出字符串中字符的所有排列

此题可以用递归,《剑指offer》上的解法简单易行。JS中对字符串的限制(只读)使得代码和C风格有别。这里我们考虑输入是一个字符数组。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function combinations(str) {
if (!str.length) {
return;
}

let len = str.length
function combine(comb, strs, start) {
if (start === len - 1) {
console.log(comb + strs[start]);
}

for (let i = start; i < len; i++) {
[strs[start], strs[i]] = [strs[i], strs[start]];
// 需要通过slice方法传递复制
combine(comb + strs[start], strs.slice(), start+1);
}
}
combine('', str, 0);
}

求字符串的组合(也可以叫求子集)

总共的解数目为$2^n$。从最低位开始,向上寻找时,有两种可能:新字符不出现,新字符出现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function subsets(str) {
if (!str.length) {
return;
}

function reduce(res, i) {
if (i === str.length-1) {
console.log(res + str[i]);
console.log(res);
return;
}

reduce(res+str[i], i+1);
reduce(res, i+1);
}

reduce('', 0)
}

输入8个数字的数组放在正方体的8个顶点,有无可能每对面对应的4个顶点和相等

相等于寻找全排列,使得a1+a2+a3+a4=a5+a6+a7+a8,且a1+a3+a5+a7=a2+a4+a6+a8,且a1+a2+a5+a6=a3+a4+a7+a8。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
function findComb(nums) {
let len = nums.length;

function combine(nums, start) {
if (start === len-1) {
if(test(nums)) {
console.log(nums);
}
}

for (let i = start; i < len; i++) {
[nums[start], nums[i]] = [nums[i], nums[start]];
combine(nums.slice(), start + 1);
}
}

function test(nums) {
return nums[0] + nums[1] + nums[2] + nums[3] == nums[4] + nums[5] + nums[6] + nums[7] &&
nums[0] + nums[2] + nums[4] + nums[5] == nums[1] + nums[3] + nums[5] + nums[7] &&
nums[0] + nums[1] + nums[6] + nums[7] == nums[2] + nums[3] + nums[4] + nums[5];
}

combine(nums, 0);
}

8皇后问题

8皇后的限制条件在,不得同行同列,不得位于同一对角线。因此,为长度为8的数组初始化填入1~8,代表行数。填入的数代表列数,因为每行每列都只有一个皇后。所以全排列中,只需要删除满足下面条件的排列即可:

  • i-j != columns[i] - columns[j]j-i != columns[i] - columns[j],即不在一个对角线上
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
function eightQueens() {
let count = 0;

function combine(queens, start) {
if (start === 7) {
console.log(queens);
count++;
}

for (let i = start; i < 8; i++) {
[queens[start], queens[i]] = [queens[i], queens[start]];
if (test(queens, start)) {
combine(queens.slice(), start+1);
} else {
[queens[start], queens[i]] = [queens[i], queens[start]];
}
}
}

function test(queens, end) {
for (let i = 0; i < end; i++) {
let diff = queens[end] - queens[i];
if (diff == end - i || -diff == end - i) {
return false;
}
}
return true;
}

combine([0,1,2,3,4,5,6,7], 0);
console.log(count);
}

这种思路会带来重复的解,按照Matrix67的思路,可以通过位运算求解。使用row(列占用情况),ld(左下右上对角线占用),rd(右下左上对角线占用)三个变量存储当前列的禁用情况。使用“1”表示禁用,“0”表示可用。解决n皇后的问题代码仅在10行左右。详细解释见链接。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function nQueens(n) {
if (!n || n <= 0) return;
let limit = (1 << n) - 1, sum = 0;
function queen(row, ld, rd) {
if (row !== limit) {
let pos = limit & ~(row | ld | rd);
while (pos) {
let p = pos & -pos; // 得到最右侧可放子的位置
pos -= p; // 除去这个位置
queen(row|p, (ld|p) << 1, (rd|p) >> 1)
}
} else sum++;
}
queen(0, 0, 0);
return sum;
}

时间效率

数组中出现次数超过一半的数字

思路1:超过一半的数组一定出现在中位数的位置,结合快排的思想,找到中位数
思路2:遍历所有数字,遇到与major相同的元素时,计数器+1,反之-1。计数器为0时,更换major。返回最后的major

注意:最后需要检查数组是否有满足条件的元素

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
function findMajor(nums) {
if (!nums.length) {
return;
}

let major;
times = 0;
nums.forEach((num) => {
if (times === 0) {
major = num;
times = 1
} else if (num === major) {
times++;
} else {
times--;
}
});

if (nums.reduce((sum, num) => { return sum + (num === major); }, 0) * 2 > nums.length) {
return major;
}
}

最小的k个数

同样的两种思路:

  1. 利用快排的中的partition,找到前k个数,时间复杂度O(n),但是会修改数组
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    function partition(arr, start, end) {
    if (start >= end) {
    return;
    }
    let index = start -1;
    let split = arr[end];
    for (let i = start; i < end; i++) {
    if (arr[i] < split) {
    index++;
    if (i != index) {
    [arr[i], arr[index]] = [arr[index], arr[i]];
    }
    }
    }
    index++;
    arr[end] = arr[index];
    arr[index] = split;
    return index;
    }

    function minKNums(nums, k) {
    if (!nums || k <= 0) {
    return;
    }

    if (k >= nums.length) {
    return nums;
    }

    let start = 0,
    end = nums.length - 1,
    index = partition(nums, start, end);

    while(index != k-1) {
    if (index > k-1) {
    end = index - 1;
    } else {
    start = index + 1;
    }
    index = partition(nums, start, end);
    }

    console.log(nums.slice(0, k));
    }
  2. 维护一个排序的长度为k的数据容器,当容器满时,根据容器最大值更新容器,时间复杂度O(nlogk),空间复杂度O(k),但是在k较小时,性能很接近
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
class MinSet {
constructor() {
//...
}

get max() {
//...
}

get len() {
//...
}

input(element) {
//...
}

remove(element) {
//...
}

toString() {

}
}

function minKNums(nums, k) {
if (!nums || k <= 0) {
return;
}

if (k >= nums.length) {
return nums;
}

let set = new MinSet();
nums.forEach(num => {
if (set.len === k) {
if (set.max.value > num) {
set.remove(set.max);
set.input(num);
}
} else {
set.input(num);
}
});

set.toString();
}

数据流的中位数

使用二叉树、最大堆最小堆完成。保证最大堆和最小堆的数目差不超过1即可。当然,首先要实现最大和最小堆

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
function inherit(son, father) {
let prototype = Object.create(father.prototype);
prototype.constructor = son;
son.constructor = prototype;
}

function Heap() {
this.heap = [];
}

Heap.prototype.parent = function(i) {
return (i+1) / 2 - 1;
}

Heap.prototype.left = function(i) {
return 2 * i + 1;
}

Heap.prototype.right = function(i) {
return 2 * i + 2;
}

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

function MinHeap() {
Heap.call(this);

function minify(index) {
let l = this.left(index),
r = this.right(index),
size = this.length;

if (l < size && this.heap[l] < this.heap[index]) {
i = l;
} else {
i = index;
}

if (r < size && this.heap[r] < this.heap[i]) {
i = r;
}
if (i != index) {
[this.heap[index], this.heap[i]] = [this.heap[i], this.heap[index]];
minify(i);
}
return;
}

this.add = function(num) {
this.heap.push(num);
minify(this.heap.length / 2 - 1);
}

this.shift = function() {
let num = this.heap.shift();
minify(this.heap.length / 2 - 1);
return num;
}

this.top = function() {
return this.heap[0];
}

}
inherit(MinHeap, Heap);

function MaxHeap() {
Heap.call(this);

function maxify(index) {
//... 同上
}

this.add = function(num) {
//... 同上
}

this.shift = function() {
let num = this.heap.shift();
maxify(this.heap.length / 2 - 1);
return num;
}

this.top = function() {
//... 同上
}
}

/* 借助上面的MinHeap和MaxHeap实现 */
function DynamicArray() {
this.min = new MinHeap();
this.max = new MaxHeap();
}

DynamicArray.prototype.insert = function(num) {
let minSize = this.min.size(),
maxSize = this.max.size();

if (minSize === maxSize) {
this.min.add(num);
// 插入在最小堆,但是数字在最大堆的范围
if (maxSize > 0 && num < this.max.top()) {
this.max.add(this.min.shift());
}
} else {
this.max.add(num);
// 同理,插入在最大堆,但是数字在最小堆的范围
if (minSize > 0 && num < this.min.top()) {
this.min.add(this.max.shift());
}
}
}

DynamicArray.prototype.getMedian = function() {
let size = this.min.size() + this.max.size();

if (!size) {
console.warn('Empty array.');
}

if (size & 0x01) {
return this.min.top();
} else {
return (this.min.top() + this.max.top()) / 2;
}
}

连续子数组的最大和

前提:数组由正负数组成。

经典的动态规划问题。用双指针的方法,采用贪婪算法,用f[i]表示以第i位结尾的最大和,当f[i-1]<=0时,可以抛弃之前的和,以当前元素为f[i]

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function maxSum(nums) {
if (!nums.length) {
return;
}

let len = nums.length;
let res = nums[0];

for (let i = 1; i < len; i++) {
if (res <= 0) {
res = nums[i];
} else {
res += nums[i];
}
}

return res;
}

1~n整数中1出现的次数

这种数学题,多半会有找规律的简单方法。普通的方法是,逐个寻找每个数字中1出现的次数,时间复杂度O(nlogn)。逐位寻找1出现的次数,再累加,时间复杂度可以降至O(logn)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
function numOf1(num) {

function count(str) {
let first = str[0];
let len = str.length;

if (str.length === 1) {
return first > 0;
}
// 第一位中1出现的次数
let firstCount = 0;
if (first > 1) {
firstCount = Math.pow(10, len-1);
} else if (first == 1) {
firstCount = +str.substr(1) + 1;
}

// 剩下的位数可以通过排列组合计算
let otherCount = first * (len - 1) * Math.pow(10, len-2);
return firstCount + otherCount + count(str.substr(1));
}

if (num < 1) {
return 0;
}

let numStr = `${num}`;
count(numStr);
}

数字序列中某一位的数字

前提:数字以0123456789101112131415...的格式序列化

需要找到规律。1位数占用10位,2位数占用180位,3位数占用2700位,……,n位数占用9 * (10 ^ n)位。通过循环确定位数后,对n取余,能知道是n位数第几个数的第几位。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
function findKthDigit(k) {
function countInDigits(n) {
return (n === 1 ? 10 : 9 * Math.pow(10, n-1));
}
// 第m个n位数的第k位
function find(n, m, k) {
// 找到n位数的起始
let start = Math.pow(10, n - 1);
let num = start + m;
return num / Math.pow(10, n - k - 1);
}

if (k < 0) {
return -1;
}

let digits = 1;
while (k >= countInDigits(digits)) {
k -= countInDigits(digits++);
}

return find(digits, k / digits, k % digits);
}

把数组排成最小的数

要求:拼接数组中的数,使之最小

此题的思路在拓展排序的规则。当组合mn < nm时,说明m应该在前,即元素间的比较准则是mn和nm的大小关系。注意大数问题,应该比较字符串。

1
2
3
4
5
function minComb(nums) {
nums.sort((a, b) => {
return ('' + a + b > '' + b + a ? 1 : -1);
});
}

把数字翻译成字符串

前提:0翻译成‘a’,1翻译成‘b’,逐位进行翻译,有时会有多种结果
要求:求可能的结果数目

动态规划,避免重复解决子问题。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
function translateCount(numStr) {
if (!numStr || +numStr < 0) {
return 0;
}

let count;
let counts = [];
for (let i = numStr.length - 1; i >= 0; i--) {
if (i === numStr.length) {
count[i] = 1;
continue;
}

count = count[i+1];
let comb = +(count[i] + count[i+1]);

if (comb <= 25 && comb >= 10) {
count += (i < numStr.length - 2 ? counts[i+2] : 1);
}
count[i] = count;
}

return count[0];
}

礼物的最大价值

前提:从m*n的格子中拿礼物,从左上角开始,每次向下或向右取一格,直到右下角
要求:礼物价值总和最大

和上面的思路很类似,通过循环替代递归。比带有备忘录的递归更省时间和空间。坐标是(i, j)的格子最大总和只取决于(i-1, j)和(i, j-1)。因此可以用一维数组代替二维数组,减少空间使用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function getMaxValue(nums) {
if (!nums.length || !nums[0].length) {
return 0;
}

let row = nums[0];
let rows = nums.length;
let columns = nums[0].length;
for (let i = 0; i < rows; i++) {
for (let j = 0; j < columns; j++) {
let up = (i > 0 ? row[j] : 0)
let left = (j > 0 ? row[j-1] : 0);
row[j] = Math.max(left, up) + nums[i][j];
}
}
return row[columns - 1];
}

最长的不包括重复字符的子字符串

要求:返回字符串的长度

用类似动态规划的思路,借助之前的结果递推。同时,使用Object存储对应字符上次出现的位置,可以保证O(n)的时间复杂度。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
function longestSubStrWithDup(str) {
if (!str.length || str.length < 2) {
return str.length;
}

let pos = {};
let strArr = Array.from(str);
let maxLen = 0;
let curLen = 0;

strArr.forEach((char, num) => {
// 此前未出现过或出现在当前最长子字符串之外
let position = pos[char];
if (typeof position !== 'undefined' || position + curLen < i) {
curLen++;
} else {
// 出现在此前的最长子字符串中时,需要更新长度值
if (curLen > maxLen) {
maxLen = curLen;
}
curLen = i - position;
}
// 更新位置
pos[char] = i;
});

if (curLen > maxLen) {
maxLen = curLen;
}

return maxLen;
}

丑数

前提:丑数是指只包含因子2、3、5的数。求给定位置的丑数

普通的思路是通过循环依次判断数字是否为丑数直到找到。在允许空间消耗时,我们用数组去存储所有的丑数,并不断更新数组。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
function uglyNum(k) {
if (k <= 0) {
return 0;
}

let count = 1;
let ugly = [1];
let ugly2 = 0;
let ugly3 = 0;
let ugly5 = 0;

while(count < k) {
uglynums.push(Math.min(ugly[ugly2]*2, ugly[ugly3]*3, ugly[ugly5]*5));
while (ugly[ugly2]*2 <= ugly[count]) {
ugly2++;
}
while (ugly[ugly3]*3 <= ugly[count]) {
ugly3++;
}
while (ugly[ugly5]*5 <= ugly[count]) {
ugly5++;
}
count++;
}

return ugly.pop();
}

第一个只出现一次的字符

哈希表,JavaScript中使用Object即可。第一次扫描时去除所有重复元素,第二次扫描时,打印第一个字符。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function firstUniqueChar(str) {
let strArr = Array.from(str);
let chars = {};
strArr.forEach(char => {
if (!chars[char]) {
chars[char] = 1;
} else {
chars[char]++;
}
});
strArr.forEach(char => {
if (chars[char] === 1) {
return char;
}
});
return false;
}

类似问题都可以用哈希表解决。

数组中的逆序对

逆序对的存在是数组未排序的结果。因此使用排序算法得到交换元素的次数即可。最直观的使用冒泡排序,时间复杂度为O(n^2)。使用归并和快排可以达到O(nlogn)的时间复杂度。

略。

两个链表的第一个公共节点

因为链表从交汇节点后拥有相同的节点,所以,从后向前回溯,直到节点不同为止即可。时间复杂度O(m+n),空间复杂度O(m+n)。若不使用辅助栈,空间复杂度还可降至O(1)。

1
2
3
4
5
6
7
8
9
10
11
function findFirstCommonNode(list1, list2) {
if (!list1 || !list2) {
return false;
}

// 遍历list1和list2到尾节点
// ...

// 向前回溯直到节点不同
// ...
}

排序数组中查找数字(二分查找)

排序数组中数字出现的次数

思路是,查找到数字第一次出现的地方和最后一次出现的地方。通过二分法查找,数字较小时,在右半边;较大时,在左半边;相等时判断左/右是否还有该元素。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
function numOfK(arr, k) {
if (!arr.length) {
return 0;
}

function findFirst(start, end) {
if (start > end) {
return -1;
}

let middle = (start + end) / 2;
let median = arr[middle];

if (median < k) {
start = middle + 1;
} else if (median > k) {
end = middle - 1;
} else {
if (middle > 0 && arr[middle-1] === median) {
end = middle - 1;
} else {
return middle;
}
}

return findFirst(start, end);
}

function findLast(start, end) {
if (start > end) {
return -1;
}

let middle = (start + end) / 2;
let median = arr[middle];

if (median < k) {
start = middle + 1;
} else if (median > k) {
end = middle - 1;
} else {
if (middle < arr.length-1 && arr[middle+1] === median) {
start = middle + 1;
} else {
return middle;
}
}

return findLast(start, end);
}

let first = findFirst(0, arr.length-1);
let last = findLast(0, arr.length-1);

if (findFirst() === -1) {
return 0;
}
return last - first + 1;
}

寻找从0~n-1中缺少的数字

数组事先排序时,借助二分法查找,时间复杂度O(logn);未排序时,通过求和和n(n-1)/2求差得出(不考虑大数问题的话)。中间元素和下标相等时,在右半边寻找;不相等时,若左侧元素相等,则返回该元素,左侧元素不等时,在左半边寻找。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
function findLeftNum(arr) {
let len = arr.length;
if (!len || len < 2) {
return;
}

let left = 0;
let right = len - 1;

while (left <= right) {
let middle = (left + right) >> 1;
if (arr[middle] === middle) {
left = middle + 1;
} else {
if (middle > 0 && arr[middle-1] !== middle-1) {
right = middle - 1;
} else {
return middle;
}
}
}

if (left > right) {
return len;
}
}

数组中和下标相等的元素

前提:数组元素都为整数,且单调递增。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
function numsEqualsToIndex(nums) {
if (!nums.length) {
return -1;
}

let left = 0,
right = nums.length-1;

while (left <= right) {
let mid = (left + right) >> 1;

if (nums[mid] === mid) {
return mid;
}

if (nums[mid] > mid) {
right = middle - 1;
} else {
left = middle + 1;
}
}

return -1;
}

二叉搜索树的第k大的节点(遍历)

利用二叉搜索树的中序遍历的特点,找到第k大的节点。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
function findKthNode(root, k) {
if (k < 1 || !root) {
return null;
}

let res = null;

if (root.left) {
res = findKthNode(root.left, k);
}

if (res === null) {
if (k === 1) {
res = root;
}
k--;
}

if (root.right) {
res = findKthNode(root.right, k);
}

return res;
}

二叉树的深度

使用递归,比较左右子树的深度,取较大值。

1
2
3
4
5
6
7
8
9
10
function treeDepth(root) {
if (!root) {
return 0;
}

let left = treeDepth(root.left);
let right = treeDepth(root.right);

return Math.max(left, right) + 1;
}

判断一棵树是否是平衡二叉树

可以使用上面的思路,递归判断除叶节点外每个节点的左右子树深度。但是这样会重复遍历节点。但是如果使用后序遍历,就可以先遍历左右子树,再回到根节点。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
function isBalanceTree(root) {
function tree(root) {
if (!root) {
return {
depth: 0,
flag: true
};
}

let left = tree(root.left);
let right = tree(root.right);

if (left.flag && right.flag) {
let diff = left.depth - right.depth;
if (diff <= 1 && diff >= -1) {
return {
depth: 1 + Math.max(left.depth, right.depth),
flag: true
};
}
}

return {
flag: false
};
}

tree(root);
}

数组中数字出现的次数(位运算)

在数组中找到唯二两个只出现一次的元素

前提:数组中其他元素都出现两次。

还是使用异或,不过这次异或的结果是两个数组的异或结果。找到结果中第一位1,说明这一位两个数不同,据此将数组分成两部分,分别异或,得到最终结果。时间复杂度O(n),空间复杂度O(1)。

1
2
3
4
5
6
7
8
9
10
function find2UniqueNums(nums) {
let sum = nums.reduce((sum, cur) => sum ^ cur);
let index = sum & -sum;
let nums1 = nums.filter(num => num & index);
let nums2 = nums.filter(num => !num & index);
return {
unique1: nums1.reduce((sum, cur) => sum ^ cur);
unique2: nums2.reduce((sum, cur) => sum ^ cur);
},
}

在数组中找到唯一一个出现一次的数字

前提:其余数字都出现3次。

使用和上面类似的思路,使用二进制表示每一个数,用数组存储各位1出现的和,累加后,将每一位数字对3取余,得到的数字就是结果的二进制表示。

和为s的若干数字(双指针)

2个数

前提:数组是单调递增的。因此可以在头尾放两个指针,向中间靠拢,逼近最终结果。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
function sumS(nums, s) {
let len = nums.length;

if (!len || len < 3) {
return [];
}

let left = 0;
let right = len - 1;

while (left < right) {
let sum = nums[left] + nums[right];

if (sum === s) {
return [nums[left], nums[right]];
} else if (sum < s) {
left++;
} else {
right--;
}
}
return [];
}

正数序列

前提:打印出和为s的连续正数序列,长度最少为1。

若使用双指针,需要先确定上下界。这里的序列起始的上界为n/2,下界是1。然后使用类似的方法,确定起始后,后移后界求序列。时间复杂度O(n^2)。然而,实际上,确定下界后,连续正数序列和与序列长度满足函数关系f(m, n) = (m + n)(n - m + 1) / 2(其中m为起始值,n为截止值)。问题变成了检验后界是否满足一元二次方程组。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
function sumSSeq(s) {
if (s < 3) {
return;
}

function print(i, j) {
for (let index = i; index <= j; index++) {
console.log(index);
}
}

for (let i = 1, limit = Math.floor(s/2); i <= limit; i++) {
let j = i+1;
while (j <= limit+1) {
let sum = (i + j) * (j - i + 1) / 2;
if (sum >= s) {
if (sum === s) {
print(i, j);
}
break;
}
j++;
}
}
}

翻转字符串(多次翻转)

翻转单词的顺序

要求:翻转一句话中所有单词的顺序。

此题可以先翻转所有字符,再翻转每个单词内的字符顺序。

1
2
3
function reverseByWord(sentence) {
return sentence.split(' ').reverse().join(' ');
}

左旋转字符串

要求:将字符串头部的若干字符转移到字符尾部。

使用类似上面的思路,可以不用substring方法,用翻转也可实现,不过在JS中无法对字符串自由写入,法2反而不如法1。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 法1,使用substring
function leftReverse(str, n) {
if (!str || n >= str.length) {
return str;
}

return str.substring(n) + str.substring(0, n);
}

// 法2,使用reverse
function leftReverse(str, n) {
if (!str || n >= str.length) {
return str;
}

let strArr = Array.from(str);
return strArr.slice(0, n).reverse().concat(strArr.slice(n).reverse()).reverse().join('');
}

队列的最大值(队列)

滑动窗口的最大值

前提:固定滑动窗口的宽度
要求:返回窗口内的最大值组成的数组

使用双端队列去维护滑动窗口内的最大值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
function maxInWindow(nums, k) {
if (!nums.length || k > nums.length) {
return;
}

let queue = [];
let res = [];

for (let i = 0; i < k; i++) {
while(nums[queue[queue.length-1]] < nums[i]) {
queue.pop();
}
queue.push(i);
}

for (let i = k; i < nums.length; i++) {
res.push(nums[queue[0]]);

while(nums[i] >= nums[queue[queue.length-1]]) {
queue.pop();
}

// 出界的大值要移出
if (queue[0] <= i - k) {
queue.shift();
}

queue.push(i);
}
res.push(nums[queue[0]]);
return res;
}

队列的最大值

要求:设计队列,使max,shift和push的时间复杂度为1。

因为在push和shift操作时,队列就像上面的滑动窗口,可以用相同方法实现max。

略。


建模能力

n个骰子的点数(化递归为循环)

要求:输入n,返回n个骰子朝上一面的点数之和的概率分布。

使用循环的角度考虑,骰子递增时,点数和为k的可能性为k-6,k-5,k-4,……,k-1的可能性之和。因此我们可以得到下面的结果。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function posibilities(n) {
if (n < 1) {
return [];
}

let res = [0, 1, 1, 1, 1, 1, 1];

for (let i = 2; i <= n; i++) {
for (let j = 6 * n; j > 0; j--) {
res[j] = res.slice(Math.max(0, j-6), j).reduce((sum, cur) => sum + (cur || 0), 0);
}
}

return res.slice(1).map(num => num / Math.pow(6, n));
}

扑克牌中的顺子

前提:从扑克牌中随机取5张,大小王可以当做任意数字,判断是否为顺子

这里我们假设传入的数组已经按照扑克牌中的大小对牌进行了排序。整体分3步,首先向数组的指定项插入数字,然后判断0的数目,最后看0的数目是否能填满undefined的个数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
function isSeq(nums) {
if (!nums.length || nums.length < 1) {
return false;
}

let numbers = [];
let zeros = 0;
let flag = false;
let index = -1;

nums.forEach(num => {
if (num === 0) {
zeros++;
continue;
}

if (!numbers[num]) {
numbers[num] = true;
} else {
// 有对子就不是顺子
return false;
}
});

numbers.forEach((num, idx) => {
if (flag) {
if (typeof num !== 'undefined') {
zeros--;
if (zeros-- < 0) {
index = idx;
return;
}
}
} else {
if (typeof num !== 'undefined') {
flag = true;
}
}
});

return numbers.slice(index).every(num => typeof num === 'undefined');

}

约瑟夫问题

字面意义的解法就不说了。根据《剑指offer》上的递推公式,可以得到f(n,m)=[f(n-1,m)+m]%n。因此,可以用循环实现。

1
2
3
4
5
6
7
8
9
10
11
function joseph(n, m) {
if (n < 1 || m < 1) {
return -1;
}

let remains = 0;
for (let i = 1; i < n; i++) {
remains = (remains + m) % i;
}
return remains;
}

股票的最大利润

也即起始、末尾数对最大差值区间。当遍历到f[i]时,寻找之前的最小值,即可得到利润,遍历整个数组即可得到最大利润,时间复杂度为O(n)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
function maxBenifit(nums) {
const len = nums.length;
if (!len || len < 2) {
return;
}

let min = nums[0];
let res;

for (let i = 1; i < len; i++) {
let benifit = nums[i] - min;
if (typeof res === 'undefined' || res < benifit) {
res = benifit;
}
if (benifit < 0) {
min = nums[i];
}
}
return res;
}

不使用控制结构语句求解1+2+3+…+n

使用递归等等价形式即可。

1
2
3
function sum(n) {
return Array(n).reduce((sum, i) => sum + i, 0);
}

不用加减乘除做加法

使用异或可以得到没有进位的结果。可以通过位与运算得到都是1的位。和异或结果异或,直到位与结果都为0为止。

1
2
3
4
5
6
7
8
9
10
11
12
function add(a, b) {
let sum = a ^ b,
extra = a & b;

while (!extra) {
let tmp = sum & extra;
sum = sum ^ (extra << 1);
extra = tmp;
}

return sum;
}

构建乘积数组

要求:给定数组A,构建数组B,其中每个元素是A中缺少对应位元素的总乘积。不能使用除法。

使用两个辅助数组分别存储数组从前到后和从后到前两段元素的乘积。空间复杂度O(n)的情况下,可以在O(n)时间上得到结果。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
function multiArr(nums) {
if (!nums.length || nums.length < 2) {
return [];
}

let m1 = 1,
m2 = 1,
mArr1 = [],
mArr2 = [],
res = [];

for (let i = 0; i < n; i++) {
m1 *= nums[i];
m2 *= nums[n - i - 1];
mArr1.push(m1);
mArr2.unshift(m2);
}

for (let i = 0; i < n; i++) {
res.push(mArr1[i] * mArr2[i]);
}

return res;
}

《黑客与画家》是Paul Graham的博文集。出版于2006年。它从一个“黑客”的角度阐述了编程,互联网程序等时兴的概念。深刻新鲜而发人深省。其对工作、语言的认识别具特色,而在学校教育和贫富分化等方面的认识却略显偏颇。

全书大致分为3部分

  • 1-4章解释黑客如何成长以及如何看待世界
  • 5-9章介绍创业、工作的方法论
  • 10-15章讨论编程语言的特点和使用方法

下面仅摘出些句子。个人评注已加粗。

为什么书呆子不受欢迎

  • 在人产生良知前,折磨是种娱乐。
  • 学校的真正目的是把儿童都关在一个地方,以便大人们白天可以腾出手来把事情做完(哈哈哈

黑客与画家

  • 编程语言是用来帮助思考程序的,而不是用来表达你已经想好的程序
  • 大多数黑客不是通过大学课程学会编程的,他们从实践中学习,13岁时就已经自己动手写程序了。
  • debug对我来说属于轻松的工作
  • 软件的部分功能就是解释自身,软件的使用方式最好能符合用户的直觉,源代码应该可以自己解释自己
  • 程序是写出来给人看的,附带能在机器上运行

不能说的话

  • (流行)第一批的接收者总是带有很强的抱负心,他们有自觉的精英意识,想把自己与普通人区分开来
  • 流行趋势确立以后,第二接收者就加入进来了,他们接受流行,不是因为想要与众不同,而是害怕与众不同
  • 与笨蛋辩论,你也会变成笨蛋

良好的坏习惯

  • 在我看来,一个人们拥有言论自由和行动自由的社会,往往最有可能采纳最优方案(真的么?

另一条路

  • (互联网程序)不需要为新版本付出额外的费用,或者做额外的准备,甚至可能你都不知道软件已经升级了
  • 互联网软件则完全不同,修改起来很方便。软件的发布过程可以分解成一系列的渐进式修改(快步小跑
  • 软件应该做到用户认为它可以做到的事情。但是,你不知道用户到底怎么想
  • 没有盗版是一种“优势”,但也是一个问题。一定数量的盗版对软件公司时有好处的。因为不管你的软件定价多少,有些用户永远不会购买
  • 要求用户做得越多,你担的风险就越大
  • 管理企业其实很简单,只要记住两点就可以了:做出用户喜欢的产品,保证开支小于收入
  • 从制造简洁的产品开始着手,首先要保证你自己愿意使用。然后,迅速作出1.0版,并不断加以改进
  • 无论如何,你都要使用自己的软件

如何创造财富

注意:标题不等于“如何致富”
  • 从经济学观点看,你可以把创业想象成一个压缩过程
  • 如果你想赚100万美元,就不得不忍受相当于100万美元的痛苦。创业公司将你所有的压力压缩到三四年
  • 通过创造有价值的东西而致富,优势不仅在于它合法,还在于它更简单,因为你只需要做出别人需要的东西就可以了
  • 金钱不是财富,而只是我们用来转移财富所有权的东西
  • 公司就是许多人聚在一起创造财富的地方,能够制造更多人们需要的东西
  • 人们需要的东西就是财富
  • 上班的日子不如上学的日子有趣,但是有人付钱给你,而不是你付钱给学校
  • 创造财富是大多数公司盈利的手段
  • 上班的日子为什么会差别这么大?不要搞糊涂了,你现在已经从顾客变成了仆人
  • 收入和风险是对称的,所以如果有巨大的获利可能,就必然存在巨大的失败可能。如果你有一个令你感到安全的工作,你是不会致富的
  • 创业的付出和回报虽然总体上是成比例的,但是在个体上是不成比例的
  • 只有在快速获得巨大利益的激励下,你才会去挑战那些困难的问题,否则你根本不愿意去碰它们

关注贫富分化

  • 进入社会以后,你不能总是靠父母养活,如果你需要什么东西,要么你自己做出来,要么做其他东西与需要的人交换金钱,再用金钱去买你想要的东西
  • 技术肯定加剧了有技术者与无技术者之间的生产效率差异
  • 技术在加大收入差距的同时,缩小了大部分的其他差距

防止垃圾邮件的一种方法

  • 我对贝叶斯方法寄予厚望,因为它的过滤能力可以随着垃圾邮件一起进化

设计者的品味

  • 他想要的学生不仅应该技术过硬,还应当能够使用技术做出优美的产品
  • 你需要的是咬牙向前冲刺的痛苦,而不是脚被钉子扎破的痛苦。解决难题的痛苦对设计师有好处,但是对付挑剔的客户的痛苦或者对付质量低劣的建材的痛苦就是另外一回事了
  • 在历史的任何时刻都有一些热点项目,一些团体在这些项目上做出伟大的成绩。如果你远离这些中心,几乎不可能单靠自己就取得伟大成果

编程语言解析

  • 程序员的时间要比计算机的时间昂贵得多
  • 长期使用某种语言,你就会慢慢按照这种语言的思维模式进行思考
  • 有些人认为编程语言应该防止程序员干蠢事,另一些人则认为程序员应该可以用编程语言干一切他们想干的事(即静态类型语言和动态类型语言
  • 事实上,有两种程度的面向对象编程:某些语言允许你以这种风格编程,另一些语言则强迫你一定要这样编程

一百年后的编程语言

  • 当我说Java不会成功时,我的意思是它和Cobol一样,进化之路已经走到了尽头(哦?
  • 一种语言的内核设计越小、越干净,它的生命力就越顽强
  • 用足够灵活的语言,聪明的程序员能写多好,笨的程序员就能写多烂
  • 一百年后的程序员最需要的编程语言就是可以让你毫不费力写出程序第一版的编程语言

拒绝平庸

  • Lisp很值得学习。你掌握它之后,会感动它给你带来的极大启发
  • 大概在1960年,Lisp语言引入了垃圾回收机制。……闭包是20世纪60年代Lisp语言引入的功能……宏也是60年代中期Lisp语言引入的,现在还是一片处女地(这里的“宏”为Lisp中的defmacro
  • 这里有一个评估竞争对手的妙招——关注他们的招聘职位

书呆子的复仇

  • 符号(Symbol)实际上是一种指针,指向存储在散列表中的字符串
  • 列表是Lisp的基本数据结构
  • 让客户感到满意才是你的设计方向。只要赢得用户,其他的事情就会接踵而至
  • JavaScript的写法比Lisp和Ruby稍微长一点,因为JavaScript依然区分语句和表达式
  • 所有这些语言都是图灵等价的
  • “任何C或Fortran程序复杂到一定程度之后,都会包含一个临时开发的、只有一半功能的、不完全符合规格的、到处都是bug的、运行速度很慢的Common Lisp实现。”
  • 想解决一个困难的问题,有三条路:1)使用一种强大的语言,2)为这个难题写一个事实上的解释器,3)你自己变成这个难题的人肉编译器(翻译过来就是语言本身/设计模式/问题抽象

梦寐以求的编程语言

  • 优秀的函数库的重要性将超过语言本身
  • 就算委员会只有两个人,还是会妨碍“再设计”,典型例子就是软件内部的各个接口有不同的人负责。这时除非两个人都同意改变接口,否则接口就无法改变

设计与研究

  • “用户需要的设计”≠“用户要求的设计”
  • 设计必须以人为本
  • 在软件领域,贴近用户的设计思想被归纳为“弱即是强”模式。
  • 一种功能有限但易于使用的软件可能对用户有更大吸引力
  • 先做出原型,在逐步加工做出成品,这种方式有利于鼓舞士气,因为它使得你随时都可以看到工作的成效

由于开发时间短,迭代速度快的特点,H5页面已大量用在各种移动端App和活动页中。响应时间作为和用户体验的重要部分,如果不能在这上面给用户可靠性和安全感,将会大大影响用户体验,进而影响用户使用产品的积极性。当页面并不复杂时,用户若能在1s内打开页面,看到信息的展示,会感觉速度还行,可以接受。当界面需要5s甚至以上才能显示时,对用户来说时不可接受的。

下文的标准和建议主要来自钉钉团队的H5性能优化方案

标准

通常来说首屏(onload)时间即资源加载完成时间建议在600ms内,首字时间时间在150ms内,根据网络类型做相应调整:

  • 2G,完全加载时间10s内,10s内占比80%
  • 3G,完全加载时间6s内,6s内占比60%
  • 4G和WiFi,完全加载时间3s内,3s内占比70%

(标准不是死的,上面只是举个范例)

这个时间可以在Chrome开发者工具里的Network中看到,也可以通过JS的performance API查询到

1
performance.timing.domContentLoadedEventEnd - performance.timing.connectStart

优化手段

资源加载

  • 按需加载,减少不必要资源的加载,不过这可能会增加页面的重绘
  • Lazyload,延迟加载,即不影响整体效果的图片展现给用户后再加载
  • 滚屏加载,根据字面意义,在下拉滚屏时加载额外数据,减少翻页时的重新加载和渲染
  • 响应式加载,即根据用户终端媒介加载不同大小的图片资源
  • 异步记载,异步加载第三方资源,防止影响本身的页面性能

此外,还有一些优化加载体验的方式:

  • 显示loading进度条,让用户能明确感知到页面的加载进度
  • 避免3xx、4xx、5xx的http状态码,因为3xx的跳转会影响加载时间,4xx和5xx通常是第三方资源的问题,可能会影响整个页面的展示。
  • favicon.ico,需要保证这个图标存在,且尽可能地小,并设置一个较长的缓存时间
  • 首次加载资源越小越好
  • 避免使用自定义中文字体(因为体积大)

图片使用

  • 格式,webp,jpg,png24/32较常用。jpg较为常用,避免使用png的大图片
  • 像素,一般来说不建议宽度超过640px
  • 合并,使用CSS Sprites,尤其是基本不变,大小类似的操作类型图标
  • 避免重设大小,即展示多大就加载多大的图片
  • 避免DataURL,DataURL是用Base64的方式,将图片变成一串文本编码放入代码的方式。尽管它能减少一次http交互的请求,但数据体积通常比二进制图片大1/3,且不会被缓存。
  • 使用替代方案,如CSS3,SVG或iconfont来完成简单的图片

服务端

  • Gzip,服务端要开启Gzip压缩
  • 设置合理的缓存时间,对静态资源,将缓存过期时间设置得长一些
  • 分域名部署,将动态资源和静态资源放置在不同的域名下,这样,静态资源请求时,不会带上动态域名中所设置的cookie头信息,从而减少http请求的大小
  • 减少Cookie,这部分数据使用的是上行流量,上行带宽更小,所以传输速度更慢,因此要尽量精简其大小。
  • CDN加速,部署CDN服务器,或使用第三方CDN服务,优化不同地域的接入速度。

代码

  • JS,CSS合并,除特殊情况外,所有JS压缩打包成一个文件,所有CSS压缩打包成一个文件,减少请求次数
  • 外联使用JS,CSS,有效利用缓存
  • 压缩HTML,JS,CSS,压缩代码可以减少大小到原来的1/3以下
  • 使用版本号标注JS、CSS等资源,让用户及时获取到最新的代码
  • 位置,通常CSS在前,JS在后。基础的JS文件,如日志,polyfill可以放在头部
  • 避免空src地址,在某些浏览器中可能会导致增加一个无效的http请求
  • 禁止CSS表达式,会让页面多次执行计算,造成卡顿等性能问题
  • 避免空css规则,降低渲染成本
  • 避免直接设置元素style,不利于复用和缓存,不过有些情况下确实是没有办法的办法

后台接口

  • 接口合并,尽量减少http请求次数
  • 缓存,在一些数据新旧敏感性不高的场景下,缓存接口数据

写个人项目是最爽的,再稀烂的代码都是自己的孩子,一把屎一把尿写出来的,总能认得。但是阅读本文的你,多半是给别人写码,面向老板编程的。你的码保不齐日后要别人维护。为了写出放心的码,提高搬砖效率,统一编码规范是必然的选择。

Boss Oriented Programming

有观众可能要问了,我们不是有lint工具吗?

就你话多

HTML

基本

doctype

每个页面开头,使用简单的<!DOCTYPE html>来启用标准模式。

1
2
3
4
5
<!-- bad -->
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">

<!-- good -->
<!DOCTYPE html>

charset

无特殊情况时,统一使用UTF-8

lang

推荐在html元素上使用lang属性

viewport

建议指定页面的viewport属性,保证移动端的友好展示

1
<meta name="viewport" content="width=device-width, maximum-scale=1.0, minimum-scale=1, user-scalable=no">

title

head必须要有title标签。

IE兼容模式

如不是特殊需要,通过edge mode通知IE使用最新的兼容模式。

1
<meta http-equiv="X-UA-Compatible" content="IE=Edge,chrome=1">

CSS和JavaScript引入

  • 引入CSS和JavaScript时不需要指明type,因为text/csstext/javascript分别是他们的默认值。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    <!-- bad -->
    <!DOCTYPE html>
    <head>
    <link type="text/css" rel="stylesheet" href="index.css">
    <script type="text/javascript" src="index.js"></script>
    </head>

    <!-- good -->
    <!DOCTYPE html>
    <head>
    <link rel="stylesheet" href="index.css">
    <script src="index.js"></script>
    </head>
  • CSS在<head></head>中引入,基础JS脚本在<head></head>引入,其余在<body>结束标签前引入

缩进

必须使用两个空格表示一个缩进层级,禁止使用tab

1
2
3
4
5
6
7
8
9
<!-- bad -->
<div>
<p>just a example</p>
</div>

<!-- good -->
<div>
<p>just a example</p>
</div>

换行

单行不能超过120个字符

标签名称和标签属性统一使用小写

1
2
3
4
5
<!-- bad -->
<Div Id="foo"></Div>

<!-- good -->
<div id="foo"></div>

综上,下面是一个建议的html脚手架

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
<meta name="viewport" content="width=device-width, maximum-scale=1.0, minimum-scale=1, user-scalable=no">
<meta name="renderer" content="webkit">
<meta name="author" content="shenlvmeng">
<meta name="description" content="描述">
<meta name="keyword" content="关键词">
<title>Foo</title>
<link rel="stylesheet" href="index.css">
</head>
<body>
<script src="index.js"></script>
</body>
</html>

标签

  • 非自闭合标签必须有开始和结束标签,自动闭合标签不建议在结尾处使用斜线/
    1
    2
    3
    4
    5
    6
    <!-- bad -->
    <div class="foo" />
    <img src="foo.png" />
    <!-- good -->
    <div class="foo"></div>
    <img src="foo.png">
  • 建议使用语义化标签
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    <!-- good -->
    <header></header>
    <section>
    <nav>Navbar</nav>
    <article>
    <time>2017</time>
    <figure></figure>
    </article>
    <aside></aside>
    </section>
    <footer></footer>
  • 避免标签嵌套层级过深,尤其是无语义的嵌套
    1
    2
    3
    4
    5
    6
    7
    8
    9
    <!-- bad -->
    <div class="foo">
    <div class="bar">
    <div class="container">
    <div class="content">
    </div>
    </div>
    </div>
    </div>

命名

标签名

必须使用小写字母加连字符的命名方式

1
2
3
4
<!-- bad -->
<MyTag></MyTag>
<!-- good -->
<my-tag></my-tag>

class属性

使用小写字母加连字符,需要在Javascript中使用时,以J_开头,接大驼峰命名

1
2
3
4
<!-- bad -->
<div class="FooBar J_foo-bar"></div>
<!-- good -->
<div class="foo-bar J_FooBar"></div>

id属性

同上。建议使用class属性关联CSS。

属性

使用双引号包裹

1
2
3
4
<!-- bad -->
<div class='foo'></div>
<!-- good -->
<div class="foo"></div>

Boolean属性

不要为Boolean属性指定值。

一个元素中 Boolean 属性的存在表示取值true,不存在则表示取值false。

1
2
3
4
5
6
7
8
9
10
11
12
<!-- bad -->
<input type="text" name="nickname" disabled="disabled">
<input type="checkbox" name="hobbies" value="bicycle" checked="checked">
<select>
<option value="FrontEnd" selected="selected">FrontEnd</option>
</select>
<!-- good -->
<input type="text" name="nickname" disabled>
<input type="checkbox" name="hobbies" value="bicycle" checked>
<select>
<option value="FrontEnd" selected>FrontEnd</option>
</select>

自定义属性

必须data-为前缀,保证可读

1
2
3
4
<!-- bad -->
<div age="10"></div>
<!-- good -->
<div data-age="10"></div>

顺序

保证可读性,一致的属性顺序可以提高压缩率。按照出现频率,依次是:

  • class
  • id
  • data-*

少用style属性

建议使用class来控制样式,可以提高可维护性和可读性。

不在属性值中使用JavaScript语句

1
2
3
4
5
6
7
8
<!-- bad -->
<a id="hello" href="javascript:alert('hi');" onclick=";(function(){alert('hello');})()">
<!-- good -->
<script>
document.getElementById("hello").addEventListener(function() {
alert('hello');
});
</script>

多媒体

  • imgsrc属性禁止留空
  • img标签添加alt属性以声明替代文本
  • 在多媒体标签内部提供指示浏览器不支持该标签的说明,如objectaudiovideo
1
2
3
4
5
6
7
8
9
10
11
12
13
14
<!-- bad -->
<img src="cat.png">
<audio controls>
<source src="foo.ogg" type="audio/ogg">
<source src="bar.mp3" type="audio/mpeg">
</audio>

<!-- good -->
<img src="cat.png" alt="cat">
<audio controls>
<source src="baz.ogg" type="audio/ogg">
<source src="qaz.mp3" type="audio/mpeg">
Your browser does not support the audio tag.
</audio>

CSS

基本

  • 推荐使用两个空格缩进
    1
    2
    3
    4
    5
    6
    7
    8
    /* bad */
    .example {
    color: #fff;
    }
    /* good */
    .example {
    color: #000;
    }
  • 单行不能超过120个字符,除非不可分割,如URL
  • 声明块左花括号前推荐添加空格
    1
    2
    3
    4
    5
    6
    7
    8
    9
    /* bad */
    .example{
    width: 100px;
    }

    /* good */
    .example {
    height: 15px;
    }
  • 声明块右花括号推荐单独成行
    1
    2
    3
    4
    5
    6
    7
    8
    /* bad */
    .example {
    width: 100px;}

    /* good */
    .example {
    height: 15px;
    }
  • 声明语句:需要有空格,前无空格
    1
    2
    3
    4
    5
    6
    7
    8
    9
    /* bad */
    .example {
    left:15px;
    }

    /* good */
    .example {
    top: 15px;
    }
  • 声明语句必须以分号结尾
    1
    2
    3
    4
    5
    6
    7
    8
    9
    /* bad */
    .example {
    left:15px
    }

    /* good */
    .example {
    top: 15px;
    }

注释

文档注释

声明在文件头部,表示文件作用

1
2
3
/**
* 这个文件的作用
*/

代码注释

传达代码上下文和目标,应该容易被人类理解。不要简单重复类名等冗余信息。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/* bad */

/* header */
.header {
text-align: center;
...
}

/* good */

/* Wrapping contents of .title and .user-info */
.header {
text-align: center;
...
}

命名

同HTML规范,小写字母加短划线-。在JavaScript中出现的类名用J_开头,后接大驼峰命名,这类的class不能出现在CSS文件中。

  • 文本内容必须使用双引号包裹
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    /* bad */
    html[lang|=zh] q:before {
    font-family: 'Microsoft YaHei', sans-serif;
    content: '“';
    }
    /* good */
    html[lang|="zh"] q:before {
    font-family: "Microsoft YaHei", sans-serif;
    content: "“";
    }
  • “0”值后不要使用单位。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    /* bad */
    .example {
    margin: 0px;
    }

    /* good */
    .example {
    margin: 0;
    }
  • 长度数值是0 - 1间的小数时,忽略整数部分的0
    1
    2
    3
    4
    5
    6
    7
    8
    9
    /* bad */
    panel {
    opacity: 0.8
    }

    /* good */
    panel {
    opacity: .8
    }
  • 颜色值必须使用小写的十六进制表示,禁止使用颜色名和rgb(),带有透明度时可以使用rgba()
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    /* bad */
    .success {
    box-shadow: 0 0 2px rgba(0,128,0,.3);
    border-color: rgb(0, 128, 0);
    }

    /* good */
    .success {
    box-shadow: 0 0 2px rgba(0, 128, 0, .3);
    border-color: #008000;
    }
  • 可以缩写时,颜色值必须缩写
    1
    2
    3
    4
    5
    6
    7
    8
    9
    /* bad */
    .success {
    background-color: #aaccaa;
    }

    /* good */
    .success {
    background-color: #aca;
    }
  • url()函数中的路径禁止带引号,绝对路径时可以省去协议名
    1
    2
    3
    .logo {
    background: url(//assets/logo.png);
    }

选择器

  • 一个rule包含多个选择器时,每个选择器必须独占一行
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    /* bad */
    .post, .page, .comment {
    line-height: 1.5;
    }

    /* good */
    .post,
    .page,
    .comment {
    line-height: 1.5;
    }
  • 属性选择器中的值必须用双引号包裹
    1
    2
    3
    p[lang|="zh"] {
    font-size: 12px;
    }
  • 选择器层级不要超过5级,靠后的选择器尽量精确
    1
    2
    3
    4
    5
    6
    7
    8
    9
    /* bad */
    .main .top .left .mod-a .content .detail {
    padding-left: 15px;
    }

    /* good */
    .content .detail {
    padding-left: 15px;
    }

属性

  • 建议相关的属性说明放在一组,并按下面顺序排列
    1. 定位(position、left、right、top、bottom、z-index)
    2. 盒子模型(display、float、width、height、margin、padding、border、border-radius)
    3. 排印(font、color、background、line-height、text-align)
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      .mod-example {
      /* 定位 */
      position: absolute;
      top: 0;
      right: 0;
      bottom: 0;
      left: 0;
      z-index: 100;
      /* 盒模型 */
      display: block;
      float: right;
      width: 100px;
      height: 100px;
      margin: 15px auto;
      padding: 10px 15px;
      border: 1px solid #ccc;
      /* 排印 */
      font: normal 13px "Helvetica Neue", sans-serif;
      line-height: 1.5;
      color: #333;
      background-color: #f5f5f5;
      text-align: center;
      }
  • 可以缩写时,建议缩写属性,如font, margin
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    /* good */
    .post {
    font: 12px/1.5 arial, sans-serif;
    }

    /* bad */
    .post {
    font-family: arial, sans-serif;
    font-size: 12px;
    line-height: 1.5;
    }
  • 尽量不使用!important声明

字体

  • font-family必须使用字体族的英文名称,其中如有空格等特殊字符,必须使用双引号包裹。
    1
    2
    3
    h1 {
    font-family: "Microsoft YaHei";
    }
  • font-family按效果从优到通用顺序编写
    1
    2
    3
    h1 {
    font-family: "Helvetica Neue", Arial, "Hiragino Sans GB", "WenQuanYi Micro Hei", "Microsoft YaHei", sans-serif;
    }
  • 需要在Windows平台下显示的内容,font-size禁止小于12px,否则会模糊不清
  • ling-height建议使用数值
    1
    2
    3
    .text {
    line-height: 1.5;
    }
  • 建议减少使用自定义中文字体,通常体积很大,很影响页面加载

响应式

  • 媒体查询必须放在尽可能相关的规则附近,不得单独编排
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    /* bad */
    /* header styles */
    /* main styles */
    /* footer styles */

    @media (...) {
    /* header styles */
    /* main styles */
    /* footer styles */
    }

    /* good */
    /* header styles */
    @media (...) {
    /* header styles */
    }

    /* main styles */
    @media (...) {
    /* main styles */
    }

    /* footer styles */
    @media (...) {
    /* footer styles */
    }
  • 媒体查询有多个条件分隔时,每个条件必须另起一行
    1
    2
    3
    4
    5
    6
    7
    @media
    (-webkit-min-device-pixel-ratio: 2), /* Webkit-based browsers */
    (min--moz-device-pixel-ratio: 2), /* Older Firefox browsers (prior to Firefox 16) */
    (min-resolution: 2dppx), /* The standard way */
    (min-resolution: 192dpi) { /* dppx fallback */
    /* Retina-specific stuff here */
    }
  • 媒体查询针对每一个种屏幕(大、中、小)的建议单独组织为一个文件
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    /* base.css */
    .element {
    }
    .element-avatar {
    }
    .element-selected {
    }
    /* base-media-small.css */
    @media (min-width: 480px) {
    .element {
    }
    .element-avatar {
    }
    .element-selected {
    }
    }

其他

  • 禁止使用JavaScript

参考

集团前端代码规约
文档与源码编写风格

名谓扫盲,实则是为自己扫盲。前些日子通过Elm的学习接触到了函数式编程的概念,发现语言风格和以C为代表的命令式编程大不相同,接触不同的编程思维还是很有助于自我提升的。在回顾的同时,这里走马观花地带过一些函数式编程的“热门词汇”。

历史故事

什么是函数式编程(Functional Programming,FP)?它从何而来?可以吃吗?这得从20世纪30年代开始讲起:

新建成的哥特式办公楼给普林斯顿大学带来一种天堂般的安全感。来自世界各地的逻辑学者应邀来到普林斯顿,他们将组建一个新的学部。正当大部分美国人还在为找不到一片面包做晚餐而发愁的时候,在普林斯顿却是这样一番景象:高高的天花板和木雕包覆的墙,每天品茶论道,漫步丛林。 一个名叫阿隆佐·邱奇(Alonzo Church)的年轻数学家就过着这样优越的生活。阿隆佐本科毕业于普林斯顿后被留在研究院。他觉得这样的生活完全没有必要,于是他鲜少出现在那些数学茶会中也不喜欢到树林里散心。阿隆佐更喜欢独处:自己一个人的时候他的工作效率更高。尽管如此他还是和普林斯顿学者保持着联系,这些人当中有艾伦·图灵约翰·冯·诺伊曼库尔特·哥德尔

在与这些人的合作下,阿隆佐设计了一个名为lambda演算的形式系统。在这种语言里面,函数的参数是函数,返回值也是函数。篇幅和本人能力限制,不对lambda演算做更多讲解。

除了阿隆佐·邱奇,艾伦·图灵也在进行类似的研究。他设计了一种完全不同的系统(后来被称为图灵机),并用这种系统得出了和阿隆佐相似的答案。到了后来人们证明了图灵机和lambda演算的能力是一样的。

到了50年代末,一个叫John McCarthy的MIT教授(他也是普林斯顿的硕士)对阿隆佐的成果产生了兴趣。1958年他发明了一种列表处理语言(Lisp),这种语言是一种阿隆佐lambda演算在现实世界的实现,而且它能在冯·诺伊曼计算机上运行!而后的诸多函数式编程语言(如Haskell,ML等)也多少收到Lisp的影响。

法则

函数式编程的思想来源Lambda演算在最初设计时就是用来解决计算相关问题,它是一种相对于“命令式编程”完全不同的编程范式,后者告诉计算机怎么做,前者着眼在从数学角度描述问题。它的特点也很明显:

  • 变量不可变,即默认带上const或是final(当然函数式编程里压根没有constfinal的概念)。这么来看,叫它为“符号”似乎更合适
  • 惰性求值,变量直到使用时才会真正计算它的值,因为这个特点,Haskell甚至允许无限列表的出现。同时,这也意味着语句的出现顺序和执行顺序并不相关。
  • 高阶函数,函数可以作为入参或是返回值,这个也被很多不那么OOP的语言借鉴去了
  • 无副作用函数只负责映射数据,更像是个管道,绝不改变外部状态,同样的输入在任何时候会得到同样的输出(测试人员笑开了花)。这一点使得函数式编程语言天生支持并发执行。
  • 一切皆函数,函数是第一公民

λ演算用来描述一种形式系统,它的语法只有三条:

语法 术语 描述
a 变量 一个代表参数或数字/逻辑值的符号或字符串
(λx.M) 定义 函数定义,.前面的标识符x为入参,M为表达式
(M N) 调用 应用函数到一个入参

例如:((λ x y. x + y) 1 2)表示1和2相加。

λ演算公理只有两个:

操作 名称 描述
(λx.M[x]) → (λy.M[y]) α变换 改变入参名不影响结果
((λx.M) E) → (M[x:=E]) β规约 将入参传入λ意味着对它做演算

还以上面的相加为例,α变换就是λ x y. x + y → λ a b. a + b;β规约就是(λ x y. x + y) a b → a + b。是不是很好理解。

通过这两个基本的公理,结合基本变量类型可以构造各种函数。如not函数,and函数,or函数,甚至if函数。

1
2
3
4
5
6
7
8
let and =
true value -> value
false value -> false
value true -> value
value false -> false

let if =
λ cond tvalue fvalue. (cond and tvalue) or (not cond and fvalue)

高阶函数

高阶函数意味着,我们可以把函数直接作为入参传入,或作为返回值返回。这早已不是函数式编程语言的专利,Python,JavaScript等也吸收了这个设计理念。

函数柯里化即部分求值,就利用了高阶函数的特点提出的技术,它使得函数可以一个一个接受入参,返回相同的计算结果。类似于下面的感觉:

1
2
3
4
5
6
function pow(i, j) {
return i^j;
}
funtion square(j) {
return pow(i, 2)
}

square函数返回的函数需要指定i才可执行。柯里的名字来自于第一次提出这个技巧的逻辑学家Haskell Curry

另外,值得注意的是,在函数式编程下,高阶函数通过将函数作为参数惰性求值实现。那命令式编程下呢,答案是闭包(lexical closure)。

递归?

函数式编程里没有状态变量(可以用其他方式实现),因此自然没有循环结构。实际上,函数式编程中的循环都是通过递归实现的。比如,斐波那契数列函数像下面这样:

1
let fact = λ n. if (n == 0) 1 (n * fact n-1)

这里fact函数引用了自身,虽然编译器可以识别这种写法,但是显然它并不符合严格的数学公理。

重新审视这个变换,我们可以通过传入自身的方式来让它“数学化”。let P = λ self n. if (n == 0) 1 (n * self(self n-1)),然后在令let fact n = P (P n)。如此这般:

1
2
3
4
5
6
7
fact 4
-> P (P 4)
-> if (4 == 0) (1) (4 * P(P 3))
-> 4 * P(P 3)
-> 4 * 3 * P(P 2)
-> 4 * 3 * 2 * P(P 1)
-> 4 * 3 * 2 * 1

可是,这个函数看上去并不自然,不像一个真正的递归函数,且λ演算的公理里并没有这样一条公理可以让你在定义函数的时候引用本身。还好,已经有人做了研究,借助Y组合子的帮助,可以实现真正的递归函数。

1
2
let Y = λ F. G(G)
G = λ self. F(self(self))

这相当于我们在λ演算公理体系中添加了一条“可以在函数调用时引用自身”。这也是证明λ演算图灵等价的关键一步。这意味着它的计算能力和计算机是一致的,能通过λ演算描述的函数一定可以由计算机计算。

Haskell

Haskell是一个纯函数式编程语言,它得名于上面提到过的Haskell Curry。Y组合子也是他发现的。

Haskell中一切都是函数,甚至没有指令式编程中变量的概念,它的变量全部都是只允许一次赋值,不可改变。

Haskell还没有一般意义上的控制流结构,如for循环,取而代之的是递归。同样,Haskell还有两个重要的特性,即无副作用和惰
性求值。偏数学的问题,用Haskell解决通常代码量都很小。下面是一个列表去重例子

1
2
3
4
5
6
cut cond  []  = []
cut cond (elem:rest) = if cond elem then
cut cond rest else elem:rest
compress [] = []
compress (elem:rest) = elem : compress
(cut (== elem) rest)

还有一个快排(不过借助了filter函数)的例子,也是短得不行

1
2
3
4
qsort (elem:rest) = (qsort lesser) ++ [elem] ++ (qsort greater)
where
lesser = filter (< elem) rest
greater = filter (>= elem) rest

Haskell中还可以定义无穷列表,如[1..]表示所有正整数。这也是惰性求值特性带来的。[1,3..] !! 42将会返回85。

Monad

Monad其实就是自函子范畴上的一个幺半群而已

这节将展示一个图文并茂的说明但并不致力于解释清楚monad到底是个什么(因为我自己也不明白)。这篇对比functor,applicatives,monad的文章写得很透彻易懂,尽管这可能并不能描述一个100%的monad。要更深刻了解monad还是需要学习范畴论的内容。

参考

函数式编程.pdf
Functional Programming For The Rest of Us
Functors, Applicatives, And Monads In Pictures

0%