递归的一个很重要的应用就是递归遍历
设想,有一家公司,职员结构可以用这个对象描述:
let company = {
sales: [{
name: 'John',
salary: 1000
}, {
name: 'Alice',
salary: 600
}],
development: {
sites: [{
name: 'Peter',
salary: 2000
}, {
name: 'Alex',
salary: 1800
}],
internals: [{
name: 'Jack',
salary: 1300
}]
}
};
或者说,一家公司有很多部门。
比如,网站部门在未来可能会分为网站 A 和 网站 B。它们可能还会再分,没有图示,脑补一下吧。
现在,如果我们需要获得所有薪酬总数,我们该如何做?
迭代方式并不容易,因为结构比较复杂。首先想到是使用 for 循环 公司,然后嵌套子循环第 1 层部门。然后我们需要更多嵌套子循环来迭代第 2 层部门,比如 网站。…然后另一个嵌套子循环给将来会有的第 3 层部门?我们应该在第 3 或第 4 层循环停止吗?如果我们将 3-4 嵌套子循环放到代码里来遍历单个对象,它会变得很丑。
我们试试递归。
我们可以看到,当函数计算一个部门的和时,有两种可能情况:
1.这个部门是有一组人的『简单』部门 —— 这样我们就可以使用简单循环来求薪酬总额。
2.或者它是一个有 N 个子部门的对象 —— 这样我们可以用 N 个递归调用来求每一个子部门的总额然后合并它们。
(1) 是递归的基础,简单的情况。
(2) 是递归步骤。复杂的任务被划分为适于更小部门的子任务。它们可能还会在划分,但是最终都会在 (1) 那里完成。
算法从代码来看会更简单些:
let company = { // 是同样的对象,简洁起见做了压缩
sales: [{name: 'John', salary: 1000}, {name: 'Alice', salary: 600 }],
development: {
sites: [{name: 'Peter', salary: 2000}, {name: 'Alex', salary: 1800 }],
internals: [{name: 'Jack', salary: 1300}]
}
};
//用来完成工作的函数
function sumSalaries(department) {
if (Array.isArray(department)) {
return department.reduce((prev, current) => prev + current.salary, 0); //求数组的和
} else {
let sum = 0;
for (let subdep of Object.values(department)) {
sum += sumSalaries(subdep); //递归调用子部门,对结果求和
}
return sum;
}
}
alert(sumSalaries(company)); //6700
代码很短也容易理解(希望是这样)。这就是递归的能力。它对任何层次的子部门嵌套都有效。
以下是调用图:
很容易可以看到原理:对对象 {…} 会生成子调用,而数组 […] 因为是递归树的「叶子」,它们会立即得到结果。
注意这段代码使用了我们之前讲过的便利特性:
递归(递归定义的)数据结构是一种复制自身的结构。
我们在上面公司结构的例子中已经见过。
一个公司的部门是:
对 web 开发者而言,有更熟知的例子:HTML 和 XML 文档。
在 HTML 文档中,一个 HTML 标签可能包括一组:
那就是一个递归定义。
为了更好的理解,我们会再讲一个递归结构的例子「链表」,在某些情况下,它是优于数组的选择。
想象一下,我们要存储一个有序的对象列表。
自然选择将是一个数组:
let arr = [obj1, obj2, obj3];
但是用数组有个问题。「删除元素」和「插入元素」操作代价非常大。例如,arr.unshift(obj) 操作必须对所有元素重新编号以便为新的元素 obj 腾出空间,而且如果数组很大,会很耗时。arr.shift() 同理。
唯一对数组结构做修改而不需要大量重排的操作就是在数组的两端:arr.push/pop。所以对大队列来说,数组会很慢。
如果我们真的需要快速插入、删除的话,我们可以选择另一种叫做链表的数据结构。
链表元素是一个被递归定义的对象,它有:
举个例子:
let list = {
value: 1,
next: {
value: 2,
next: {
value: 3,
next: {
value: 4,
next: null
}
}
}
}
let list = { value: 1 };
list.next = { value: 2 };
list.next.next = { value: 3 };
list.next.next.next = { value: 4 };
这里我们清楚的看到有很多个对象,每一个都有 value 和 指向邻居的 next。list 是链条的第一个对象,顺着 next 指针,我们可以抵达任何元素。
列表可以很容易被分为几个部分,然后重新组装回去:
let secondList = list.next.next;
list.next.next = null;
list.next.next = secondList;
当然我们可以在任何位置插入或移除元素。
比如,为了在前面增加一个新值,我们需要更新列表的头:
let list = { value: 1 };
list.next = { value: 2 };
list.next.next = { value: 3 };
list.next.next.next = { value: 4 };
// 将新值添加到列表头部
list = { value: "new item", next: list };
list.next = list.next.next;
我们让 list.next 从 1 跳到值 2。值 1 就从链上被去除。如果没有被存储在其它地方,那么它会自动的从内存中被移除。
与数组不同,没有大规模重排,我们可以很容易的重新排列元素。
当然,链表不总是优于数组。不然大家都去使用链表了。
主要的不足就是我们无法轻易通过它的编号获取元素。在数组中却很容易:arr[n] 是一个直接引用。而在列表中,我们需要从起点元素顺着 next 找 N 次才能获取到第 N 个元素。
…但是我们并不总需要这样的操作。比如,当我们需要一个队列或者甚至一个双向队列 —— 有序结构必须可以快速的从两端添加、移除元素。
有时也值得添加一个名为 tail 的变量来跟踪列表的末尾元素(并且当从尾部添加、删除元素时更新它)。对大型数据集来说,它与数组的速度差异巨大。
术语:
list = { value, next -> list }
像 HTML 元素树或者本章的部门树等树结构本质上也是递归:它们有分支,而且分支又可以有分支。
如我们在例子 sumSalary 中看到的,递归函数可以被用来遍历它们。
任何递归函数都可以被重写为迭代形式。这是优化时做的事情。对多数任务,递归解决方式足够快且容易编写和维护。