记录本题的最终实现、函数参数与返回值的传递机制,以及解构赋值、闭包、严格相等和命名习惯。

1. 实现代码

双指针,并将交换操作拆成独立函数,通过数组和下标修改原数组。

ts
function swap(nums: number[], i: number, j: number): void {
    [nums[i], nums[j]] = [nums[j], nums[i]];
}

function moveZeroes(nums: number[]): void {
    const n = nums.length;
    let currentSlot = 0;
    let processingIndex = 0;

    while (processingIndex < n) {
        if (nums[processingIndex] !== 0) {
            swap(nums, currentSlot, processingIndex);
            currentSlot++;
        }

        processingIndex++;
    }
}

时间复杂度为 O(n),额外空间复杂度为 O(1)。函数直接修改传入的数组,不需要返回新数组。

2. 笔记

2.1 参数传递:变量重新赋值与对象内容修改

TypeScript 沿用 JavaScript 的运行时语义,函数参数采用按值传递(pass by value)。形参也是变量,调用函数时,程序用实参的值初始化形参,这与用一个变量的值初始化另一个变量遵循相同的规则。

number、string、boolean 等属于原始值。以数字变量为例:

ts
let a = 1;
let b = a;

b = 2;

console.log(a); // 1
console.log(b); // 2

b = a 将 a 保存的数字值复制给 b。两个变量分别保存自己的值,随后执行 b = 2,只让 b 保存新的数字值,a 仍然保存 1。原始值不可变:数字 1 本身不会变成数字 2,赋值改变的是变量保存哪个值。数字传给函数后,给形参重新赋值也只会改变形参自己的值。

数组、普通对象等都属于对象。每个对象具有自己的身份,变量保存的是访问对象的引用。对象与保存引用的变量是分开的,多个变量可以通过各自的引用访问同一个对象。

传入对象时,复制的是对象引用,对象本身不会被复制,因此形参与调用者的变量最初访问的是同一个对象。此后,需要区分两种操作:

  • 通过形参修改对象内容:沿着形参保存的引用找到对象,再修改对象的元素或属性。对象发生了变化,其他访问同一个对象的变量也能观察到变化。
  • 给形参重新赋值:改变形参保存的引用,让它访问另一个对象。调用者的变量仍然引用原对象,原对象的内容不会因这次重新赋值而改变。
ts
function update(items: number[]): void {
    // 此时:items → 数组 A,nums → 数组 A

    // 修改数组 A 的内容,数组 A 变为 [99, 2]
    items[0] = 99;

    // 创建数组 B,让 items 保存数组 B 的引用
    // 执行后:items → 数组 B,nums → 数组 A
    items = [7, 8];

    // 修改数组 B 的内容,数组 A 不受影响
    items[0] = 100;

    console.log(items); // [100, 8],数组 B
}

// 创建数组 A,让 nums 保存它的引用
const nums = [1, 2];

update(nums);

console.log(nums); // [99, 2],数组 A

items[0] = 99 修改的是引用所指向的对象内容;items = [7, 8] 修改的是变量保存的引用。

const 约束的是变量不能被重新赋值,不会自动冻结对象。因此,示例中的 nums 不能被重新赋值,但其数组元素仍然可以修改。

2.2 多值返回:使用元组描述返回结果

函数每次调用返回一个值,这个值可以是包含多个元素的数组。TS 可以用元组类型描述各个位置的类型:

ts
function swapped(a: number, b: number): [number, number] {
    return [b, a];
}

const [x, y] = swapped(1, 2);

console.log(x); // 2
console.log(y); // 1

[number, number] 是元组类型,描述一个包含两个数字的位置结构。元组在运行时仍然是数组,类型标注用于静态检查。

返回值也遵循相同的值语义:返回原始值,调用者得到该原始值;返回对象,调用者得到访问该对象的引用,不会自动复制对象。

本题可以采用两种接口:swap(nums, i, j) 直接修改数组,返回类型为 void;swapped(a, b) 返回交换后的结果,由调用者负责赋值。最终实现采用前一种。

2.3 解构声明、解构赋值与交换

解构可以将数组中的元素按位置分配给多个变量。

ts
const [x, y] = swapped(1, 2);

给已有变量赋值时,不重复声明变量:

ts
let a = 1;
let b = 2;

[a, b] = swapped(a, b);

直接交换两个变量可以写成:

ts
[a, b] = [b, a];

右侧先读取交换前的 b 和 a,随后才向左侧赋值,因此不会丢失其中一个原值。

解构赋值的目标也可以是数组元素:

ts
[nums[i], nums[j]] = [nums[j], nums[i]];

对于简单变量,解构赋值仍然遵守 let、const 的约束:已有的 const 变量不能被重新赋值;通过 const 数组变量修改元素则是允许的。

2.4 箭头函数与闭包

箭头函数是 TS 中常用于表达 lambda 的函数写法:

ts
const add = (a: number, b: number): number => a + b;

函数体只有一个需要返回的表达式时,可以省略花括号和 return。使用花括号时,返回值需要显式写 return:

ts
const add = (a: number, b: number): number => {
    return a + b;
};

闭包是函数与其所能访问的外层词法环境的组合。函数可以访问定义位置的外层变量,并在需要时保留这种访问能力。普通函数和箭头函数都可以形成闭包。

例如,将交换函数定义在 moveZeroes 内部,就可以访问外层的 nums:

ts
function moveZeroes(nums: number[]): void {
    const swap = (i: number, j: number): void => {
        [nums[i], nums[j]] = [nums[j], nums[i]];
    };

    const n = nums.length;
    let currentSlot = 0;
    let processingIndex = 0;

    while (processingIndex < n) {
        if (nums[processingIndex] !== 0) {
            swap(currentSlot, processingIndex);
            currentSlot++;
        }

        processingIndex++;
    }
}

这里的 swap 只接收下标,数组通过外层作用域取得。它访问的是定义位置的外层变量,不是任意调用者的局部变量。

函数也能直接修改外层可写的原始值变量:

ts
let count = 0;

const increment = (): void => {
    count++;
};

increment();
console.log(count); // 1

这里赋值的目标就是外层的 count,不涉及通过形参写回调用者变量。闭包访问的是变量绑定,不是定义函数时冻结的一份值快照。

2.5 ===、!==:严格相等与对象身份

=== 和 !== 分别是严格相等与严格不相等运算符,比较时不会为了统一类型而进行隐式类型转换。!== 的结果始终是 === 结果的取反。

对于原始值,比较依据是类型及该类型的值规则。类型不同,=== 返回 false;类型相同时,数字按数值比较,字符串按字符序列比较,布尔值按 true、false 比较。数字中的两个特殊规则是:NaN 与自身严格不相等,+0 与 -0 严格相等。

对于对象,比较依据是对象身份,不会递归比较内容。两个引用访问同一个对象时严格相等;分别创建的两个对象,即使内容相同,也严格不相等。

ts
const a = [1, 2];
const b = a;
const c = [1, 2];

console.log(a === b); // true:访问同一个数组
console.log(a === c); // false:访问两个不同的数组
console.log(a !== c); // true

== 和 != 使用允许隐式类型转换的比较规则。下面展示 JavaScript 的运行时结果:

js
0 == "0";  // true
0 === "0"; // false
0 !== "0"; // true

2.6 命名规则与变量声明习惯

TS 通常采用以下命名习惯:

用途常见风格示例
变量、参数、函数、属性、方法camelCasecurrentSlot、processingIndex、moveZeroes
类型别名、接口、类PascalCaseListNode、UserProfile
部分固定常量UPPER_SNAKE_CASEMAX_RETRIES

没有重新赋值的变量优先使用 const,需要重新赋值的变量使用 let。本题中,n 使用 const,两个不断移动的下标使用 let。