数据结构 — 狼人松鼠的 90 天记录
这一章讲三件事: 把多个值装进一个值的两种容器(数组、对象); 「同一」与「相同」的区别为什么是本章最重要的理论;以及一个完整的小型数据分析—— 主角每天记录自己做了什么、是否变成了松鼠,我们用统计帮他从 90 天记录里揪出元凶。 读完你会拥有一整套「数据怎么摆」的词汇,和一条从原始记录到结论的主走查。
1. 先看故事:结构跟着问题走
Jacques 每晚八点到十点之间会变成一只毛茸茸的松鼠。他怀疑变化由某件事触发,于是每天记录做了什么、有没有变,指望从一天的记录里找出规律1。
注意书的顺序:先有问题(找触发因素),再有数据结构。Babbage 那句著名的反问放在章首当题词——「给机器喂错的数,能出来对的答案吗?」——就是在提醒:结构错了,后面全错。
2. 数组:一排按序号取的格子
要把 2、3、5、7、11 装在一起,数组(array)是正解:方括号、逗号隔开2。
let listOfNumbers = [2, 3, 5, 7, 11];
listOfNumbers[2]; // → 5
listOfNumbers[0]; // → 2
listOfNumbers[2 - 1]; // → 3 括号里是表达式,先算再取
下标从 0 开始。书里给的适应方法很实用:把下标读成「从头要跳过几个」——[0] 是跳过 0 个,即第一个3。
3. 属性与方法:几乎一切值都带「抽屉」
属性(property) 是挂在值上的东西:"text".length、Math.max 都是在取属性。取法两种,含义不同:
- 点号
value.x:x 是字面的属性名; - 方括号
value[x]:括号里是表达式,先求值,把结果(转成字符串)当属性名4。
例外只有两个:null 和 undefined 没有属性,取了就报 TypeError5。
属性里装着函数时,它有个专名叫方法(method),如 toUpperCase、数组的 push(尾加)与 pop(尾取并返回)。push/pop 这对名字来自「栈」——后进先出的结构,正是上一章调用栈的那个「栈」6。
4. 对象:一只有名字触手的章鱼
数组适合「一排同类的东西」;生活记录的一条却不同:一个活动列表 + 一个变没变。把它们捆成一个值,用对象(object):
let day1 = {
squirrel: false,
events: ["work", "touched tree", "pizza", "running"]
};
day1.wolf; // → undefined(没有的属性读出来就是 undefined)
day1.wolf = false; // 赋值即创建
延续触手比喻:对象是一只有很多触手的章鱼,每条触手上写着名字;delete 剪掉一条触手;in 问「有没有这条触手」。把属性设成 undefined 和 delete 不一样:前者触手还在、只是抓了个空,in 仍为 true7。
花括号在 JS 里有两义:语句开头是语句块,其他位置是对象。由此有个著名的坑:箭头函数直接返回对象要加括号——n => ({prop: n}),否则花括号被当成函数体8。
配套小工具:Object.keys 列出属性名;Object.assign 把一个对象的属性抄进另一个9。而数组呢?数组就是对象——typeof [] 是 "object",只不过它的触手按数字排成一排。书里的画法:「长而扁的章鱼」10。
5. 可变性与身份:本章的理论核心
字符串、数字、布尔是不可变的:"cat" 的任何代码都改不成 "rat",你只能造新字符串。对象不同:同一个对象可以在不同时刻有不同内容11。
于是「相等」裂成两个概念12:
let object1 = {value: 10};
let object2 = object1; // 两条触手抓同一只章鱼
let object3 = {value: 10}; // 另一只内容相同的章鱼
object1 == object2 // → true 同一个东西
object1 == object3 // → false 内容相同也不是它
object1.value = 15;
object2.value; // → 15(跟着变)
object3.value; // → 10(各过各的)
JS 的 == 对 对象只比身份、不比内容,也没有内建的「深比较」(想比内容得自己写,这是章末练习)13。const 与此是两回事:const 锁的是触手不换目标,章鱼的内容照样能改——const score = {visitors: 0}; score.visitors = 1 合法,score = {} 报错14。
6. 主走查:90 天记录揪出花生
现在做正事。那本记录是对象数组:journal 里每条是 {events: […], squirrel: true/false}15。
问题:哪个活动与「变松鼠」相关?书里请出统计学里度量两个布尔变量关联的 phi 系数(φ):把所有日子按「变没变 × 做没做某事」数进 2×2 表,公式输出 -1 到 1,0 表示无关16。
第一步,把「吃披萨」的四格表数出来,压扁成四元素数组 [76, 9, 4, 1](下标是两位二进制:松鼠位在左、事件位在右)。算 φ:
φ = (76×1 − 4×9) / √(5 × 85 × 10 × 80)
= (76 − 36) / √340000
≈ 0.069 ← 趋近 0:吃披萨与变身无关
第二步,对每个事件都算一遍。遍历记录、累加四格的函数 tableFor 里,四格表的下标计算是本章走查的核心一步:某天没做该事件、也没变松鼠 → index 0;做了事件 → +1;变了松鼠 → +2;两者都发生 → index 3。90 天各归各格17。
全事件相关性算完,筛掉接近 0 的,只剩几个显眼的:peanuts 0.59(强正相关)、brushed teeth −0.38(强负相关)18。
第三步,作者的神来之笔——造一个组合事件「peanut teeth」:吃了花生且没刷牙的日子:
phi(tableFor("peanut teeth", JOURNAL));
// → 1
完美相关。 真相是:Jacques 吃了花生又不刷牙才会变身。他戒掉花生,变身停止;故事的结尾,他索性在树上盖了小屋、备好十年花生酱,选择当一只全职松鼠——全书最著名的幽默结局19。
回看这条链:问题 → 表示(对象数组)→ 遍历累加(四格表)→ 公式(φ)→ 过滤 → 组合特征(把两个信号并成一个新信号)。 它就是一次完整的数据分析,而每一步都建立在本章前半的容器上。
7. 工具箱:数组与字符串的常用武器
| 工具 | 干什么 | 怎么记 |
|---|---|---|
push / pop | 尾加 / 尾取 | 栈 |
unshift / shift | 头加 / 头取 | 组成任务队列:remember 排队,getTask 取最前20 |
indexOf / lastIndexOf | 找位置,找不到给 -1 | 可带第二个参数定起点21 |
slice(start, end) | 切一片,含头不含尾 | 不给 end 就切到底;两个都省=复制整个数组22 |
concat | 拼接出新数组 | 与 slice 组合可实现「删一个元素但不改原数组」23 |
字符串有一批同名同义的:slice、indexOf(而且能找多字符)、trim(去两端空白)、split / join(按指定的分隔串拆开/粘回)、repeat(重复)、padStart(补齐到定长——上一章手写的 zeroPad 就是它)24。但原始类型存不了自定义属性:kim.age = 88 不报错也不生效,再读是 undefined25。
三件现代语法糖:rest 参数(function max(...numbers) 把余下参数收成数组,书里用 -Infinity 当起点写出 max);展开(max(...numbers) 把数组拆回参数;对象字面量里 {...coordinates, y: 5} 抄属性,后者覆盖前者);解构(function phi([n00, n01, n10, n11]) 直接在参数位拆数组——同一章的 φ 函数立刻用它重写了一遍;let {name} = obj 拆对象)26。
两个零碎:Math.random() 每次给一个 0 到 1 之间的伪随机数(机器存着隐藏值,每次对它做复杂运算派生新数);a?.b 可选链——a 是 null/undefined 时静默得 undefined 而不是报错27。
8. JSON:把地址的纠缠压扁
对象和数组在内存里是地址的纠缠:数组套数组,外壳里存的其实是内壳的位置。想存进文件或发到另一台机器,就得序列化(serialization)——转成一份扁平描述28。
最流行的格式是 JSON——JavaScript Object Notation,一种数据交换文本格式,长得像 JS 的对象字面量,但有纪律:属性名必须双引号;不许函数、不许计算、不许注释——只能装「数据」29。JSON.stringify 把值变成 JSON 字符串,JSON.parse 反向变回值。这一对函数会在第 13 章(浏览器本地存储)和第 14 章(后端)里反复出现。