普通视图

发现新文章,点击刷新页面。
今天 — 2026年1月19日首页

【面试必问】手撕 LeetCode “三数之和”:双指针+去重,这一篇图解给你讲透!

作者 NEXT06
2026年1月18日 21:12

前言:为什么你总倒在“三数之和”?

太多面试者在 LeetCode 第1题 “两数之和”  上重拳出击,用 HashMap 秒杀全场;然而一旦题目变成 第15题 “三数之和” ,由于无法直接套用 Hash 策略,很多人就开始支支吾吾,最后写出了一个 

O(n3)

 的暴力三层循环。

兄弟们,面试写

O(n3)

,基本上就是“回家等通知”的节奏了,还能打包一份"凉面"。  

其实,这道题是考察  “排序 + 双指针”  的经典范例。它的难点不在于找数字,而在于 如何优雅地去重。今天,就带你一步步拆解这道大厂必考题,保证你下次遇到能手撕得明明白白!


一、核心思路:降维打击(排序 + 双指针)

解决“三数之和”的核心在于将 三维问题降低到二维

如果我们固定其中一个数字(假设为 nums[i]),那么问题就变成了:在剩下的数组中,找到两个数 left 和 right,使得 nums[left] + nums[right] = -nums[i]

看!这不就变回我们熟悉的“两数之和”了吗?

但是,为了让双指针能跑起来,我们需要一个前提:数组必须是有序的

1. 为什么要排序?

这就要我们深刻理解 sort 的意义:

JavaScript

// a - b < 0  => a 在前 b 在后 (升序)
nums.sort((a, b) => a - b);

排序有两个巨大的好处:

  1. 单调性:数组有序后,如果三数之和偏大,我们只能通过左移右指针来减小总和;反之亦然。这是双指针能生效的物理基础。
  2. 方便去重:重复的元素会挨在一起,我们只需要判断 nums[i] === nums[i-1] 就能轻松跳过重复项。

2. 双指针布局

  1. 一层 for 循环,索引 i 从 0 到 length-2,这是我们的固定桩
  2. left 指针指向 i + 1(桩子的下一位)。
  3. right 指针指向 length - 1(数组末尾)。

屏幕截图 2026-01-18 210516.png


二、动图级流程解析:指针怎么动?

一旦 i 固定了,left 和 right 就开始向中间靠拢。在这个过程中,我们计算 sum = nums[i] + nums[left] + nums[right]。

这里有三种情况,逻辑非常清晰:

  1. sum > 0(和太大了)

    • 原因:数组是升序的,右边的数太大。
    • 动作:right--(右指针左移,找个小点的数)。
  2. sum < 0(和太小了)

    • 原因:左边的数太小。
    • 动作:left++ (左指针右移,找个大点的数)。
  3. sum == 0(中奖了!)

    • 动作:把 [nums[i], nums[left], nums[right]] 加入结果集 res。
    • 关键点:不仅要记录,还要同时收缩 left++ 和 right--,继续寻找下一组可能的解。

屏幕截图 2026-01-18 210923.png


三、地狱级细节:如何优雅地去重?💀

这道题 80% 的挂科率都出在去重上。题目要求结果集中不能包含重复的三元组(例如不能出现两个 [-1, 0, 1])。

我们在两个维度进行去重:

1. 外层循环去重(固定桩去重)

代码:

JavaScript

if (i > 0 && nums[i] == nums[i-1]) {
    continue;
}

面试官追问:  为什么是 nums[i] == nums[i-1] 而不是 nums[i] == nums[i+1]?

解析:

  • 如果是 nums[i] == nums[i+1],对于数组 [-1, -1, 2],当 i 指向第一个 -1 时,你就把它跳过了。那你就会漏掉 [-1, -1, 2] 这个有效解(因为这两个 -1 是不同位置的,可以共存)。
  • 我们用 nums[i] == nums[i-1] 的意思是: “如果当前的数字和上一个数字一样,说明上一个数字已经把所有可能的组合都找过了,我就不用再找一遍了”

2. 内层双指针去重

找到一个答案后,还没完!left 和 right 移动后的新位置可能还是和刚才一样的数字。
代码:

JavaScript

while(left < right && nums[left] == nums[left-1]) { left++; }
while(left < right && nums[right] == nums[right+1]) { right--; }

这步操作必须在 res.push 并且常规移动 left++/right-- 之后进行,确保彻底跳过重复段。


四、完整代码展示 (可以直接背诵版)

优化版本,加上了详细的注释:

JavaScript

function threeSum(nums) {
    const res = [];
    
    // 1. 必须先排序!这是双指针生效的前提
    // sort 是 JS 内置排序,a-b < 0 表示升序
    nums.sort((a, b) => a - b);
    
    const len = nums.length;
    
    // 2. 遍历每一个数字作为“固定桩” i
    for (let i = 0; i < len - 2; i++) {
        
        // 【核心去重1】:跳过重复的起点
        // 注意是 i > 0 且和 i-1 比,不是和 i+1 比
        if (i > 0 && nums[i] === nums[i-1]) {
            continue;
        }
        
        let left = i + 1;
        let right = len - 1;
        
        while (left < right) {
            const sum = nums[i] + nums[left] + nums[right];
            
            if (sum === 0) {
                // 找到一组解
                res.push([nums[i], nums[left], nums[right]]);
                
                // 无论如何都要移动指针
                left++;
                right--;
                
                // 【核心去重2】:跳过重复的 left
                while (left < right && nums[left] === nums[left-1]) {
                    left++;
                }
                
                // 【核心去重3】:跳过重复的 right
                while (left < right && nums[right] === nums[right+1]) {
                    right--;
                }
                
            } else if (sum < 0) {
                // 和太小,左指针右移让和变大
                left++;
            } else {
                // 和太大,右指针左移让和变小
                right--;
            }
        }
    }
    return res;
}

五、复杂度分析

  • 时间复杂度

    O(n2)
    
    • 数组排序通常是快排,复杂度 

      O(nlog⁡n)
      

    • 双指针遍历过程:外层循环 

      O(n)
      

      ,内层双指针 

      O(n)
      

      ,乘积是 

      O(n2)
      

    • 总体:

      O(n2)
      

      ,远优于暴力的 

      O(n3)
      

  • 空间复杂度

    O(1)
    

     或 

    O(log⁡n)
    
    • 如果你不计算存储结果的 res 数组,额外的空间主要是排序算法栈的空间(取决于语言底层实现),通常认为是 

      O(log⁡n)
      

       或 

      O(1)
      


六、总结

做这道题,心里要默念这句四步口诀

  1. 一排序:无序没法玩,sort 走在前。
  2. 二定桩:for 循环定 i,去重要判前(i-1)。
  3. 三双指:left、right 两头堵,大了左移小右顾。
  4. 四去重:找到答案别停步,while 循环跳重复。

学会了吗?别光看,赶紧打开编辑器 手撕 一遍吧!觉得有用的兄弟,点个赞再走呗!

昨天 — 2026年1月18日首页

LeetCode 11. 盛最多水的容器

作者 NEXT06
2026年1月17日 23:17

图解算法:为什么一定要移动那个短板?| LeetCode 11. 盛最多水的容器

前言:在面试中,有一类题目看似简单,暴力解法也能做,但面试官真正想看的是你如何将 

O(N2)

 的复杂度优化到 

O(N)

。LeetCode 11 题“盛最多水的容器”就是这类题目的典范。今天我们不背代码,而是深入探讨背后的贪心策略双指针思维。

一、 题目直觉与“木桶效应”

题目的目标非常直观:在一个数组中找到两条垂线,使得它们与 X 轴围成的容器能盛最多的水。

我们要计算的是矩形面积:

Area=Width×HeightArea=Width×Height

这里有一个物理常识至关重要,那就是木桶效应 (Short Board Effect)
一个木桶能装多少水,取决于最短的那块木板。

映射到题目中:

  • 宽度 (Width) :两条垂线在 X 轴上的距离 right - left。
  • 高度 (Height) :两条垂线中较矮的那一条,即 Math.min(height[left], height[right])。

二、 痛点:为什么暴力解法不行?

最容易想到的思路是双重循环:计算所有两两组合的面积,然后取最大值。

然而以我的经验,当你写下双循环的时候,你自己心中的无奈,没有人会比你更了解

面试官在了解到你的解题思路时,就已经将你pass掉了

任何算法题,写双循环的结果只有死路一条(因为他会认为你对空间与时间复杂度没有概念,或者你的实力就这么多)

JavaScript

//  暴力解法
let max = 0;
for (let i = 0; i < len; i++) {
    for (let j = i + 1; j < len; j++) {
        // 计算每一对组合...
    }
}

这种解法的时间复杂度是

O(N2)


题目提示中数组长度 

NN

 可达 

105105

。这意味着计算量高达 

10101010

 次。在通常的算法竞赛或面试标准中,这绝对会触发 TLE (Time Limit Exceeded)  超时错误。

我们需要一种更聪明的做法,将复杂度降维打击到

O(N)

三、 核心:双指针法与贪心策略

我们要优化的核心是:如何尽可能少地遍历,却能保证不漏掉最大值?

1. 初始布局:拉满宽度

既然面积 = 宽 × 高,我们不妨先让宽度最大
我们在数组的头尾各放置一个指针:left 指向开头,right 指向结尾。

此时,容器的底宽是最大的。接下来的每一步移动,宽度必然减小。为了弥补宽度的损失,我们必须寻找更高的垂线。

2. 决策困境:移动哪一根?

这是本题最难理解的点。假设现在的状况是:

  • 左边柱子高度 left_h = 2

  • 右边柱子高度 right_h = 8

  • 当前宽度 w = 10

  • 当前面积 = 

    10=202×10=20
    

现在我们需要向内移动一个指针,是移左边的(矮的),还是移右边的(高的)?

假设我们移动高的那一边(右边):

宽度肯定变小了(变成 9)。
而水位高度取决于谁?依然是左边那个不动的短板(高度 2)。
无论右边新遇到的柱子是高耸入云还是矮小不堪,容器的有效高度最高只能是 2

新面积=9×min⁡(2,新高度)≤18新面积=9×min(2,新高度)≤18

结论:  移动高板,宽度减小,高度受限于不动的短板(无法增加)。面积只会变小,绝对不可能变大。  这是一条死路。

贪心策略:移动矮的那一边(左边):

虽然宽度变小了(变成 9),但我们抛弃了当前的短板(高度 2)。
如果运气好,左边新遇到的柱子高度是 10,那么新的有效高度就变成了 8(受限于右边)。

新面积=9×8=72新面积=9×8=72

结论:  只有移动短板,我们才有可能找到更高的柱子来弥补宽度的损失。

这就是本题的贪心逻辑:  每一步我们都排除掉那个“导致当前高度受限”的短板,因为它已经发挥了它的最大潜力(在当前最宽的情况下),保留它没有任何意义。

四、 代码实现

理解了上述逻辑,代码实现就非常简单了。

JavaScript

/**
 * @param {number[]} height
 * @return {number}
 */
var maxArea = function(height) {
    // 1. 定义双指针,分别指向头尾
    let left = 0;
    let right = height.length - 1;
    let maxWater = 0;
    
    // 2. 当指针未相遇时循环
    while (left < right) {
        // 3. 计算当前面积
        // 高度取决于短板 (木桶效应)
        const currentHeight = Math.min(height[left], height[right]);
        const currentWidth = right - left;
        
        // 更新历史最大值
        maxWater = Math.max(maxWater, currentHeight * currentWidth);
        
        // 4. 核心决策:移动较矮的一侧
        // 如果左边是短板,那左边这块板子在当前宽度下已经发挥了最大价值,
        // 再往里缩宽度只会变小,保留左边没意义,不如向右移试试看有没有更高的。
        if (height[left] < height[right]) {
            left++;
        } else {
            right--;
        }
    }
    
    return maxWater;
};

五、 复杂度分析

  • 时间复杂度:

    O(N)
    

    双指针 left 和 right 总共遍历整个数组一次。相比于暴力解法的 

    O(N2)
    

    ,效率提升是巨大的。

  • 空间复杂度:

    O(1)O(1) 
    

    我们只需要存储指针索引和 maxWater 几个变量,不需要额外的数组空间。

六、 总结

所谓算法优化,往往不是代码写得有多复杂,而是思维模型的转换

LeetCode 11 题通过观察“木桶效应”,让我们明白:保留长板、抛弃短板是唯一可能获得更大收益的路径。这种通过排除法将搜索空间从二维矩阵(所有组合)压缩到一维线性扫描(双指针)的过程,就是算法中的降维打击

希望这篇文章能帮你彻底搞懂双指针解法!

从 Generator 到 Async/Await:彻底搞懂 JS 异步编程的终极解决方案

作者 NEXT06
2026年1月17日 22:24

在 JavaScript 的发展长河中,异步编程一直是开发者最头疼的痛点之一。从最早的回调函数,到 Promise 的链式调用,再到如今的 Async/Await,我们一直在追求一个终极目标:用同步的思维,写异步的代码。

今天,我们不谈枯燥的 API 文档,而是深入底层,从 Generator 原理出发,彻底搞懂为什么 Async/Await 被称为 JS 异步编程的“终极解决方案”。

一、 为什么我们需要 Async/Await?

要理解一项技术,必须先理解它要解决的问题。

1. 回调地狱(Callback Hell)的梦魇

在 ES6 之前,异步操作严重依赖回调函数。一旦业务逻辑复杂,比如需要串行请求 A、B、C 三个接口,代码就会变成这样:

JavaScript

getData(function(a) {
    getMoreData(a, function(b) {
        getEvenMoreData(b, function(c) {
            console.log(c); // 著名的“金字塔”代码
        });
    });
});

这种代码可读性差、难以调试、且错误处理极其繁琐

2. Promise 的进步与局限

Promise 的出现将回调嵌套扁平化了,它通过链式调用(.then())解决了“金字塔”问题:

JavaScript

getData()
  .then(a => getMoreData(a))
  .then(b => getEvenMoreData(b))
  .catch(err => console.error(err));

这无疑是巨大的进步。但它依然不够完美:大量的 .then 破坏了代码的语义连续性,我们依然无法像写同步代码那样直观地表达逻辑。

我们的终极诉求是:能否让异步代码看起来就像 const a = logic(); const b = logic(a); 这样符合人类线性直觉?

答案就是 Async/Await

二、 核心原理:并非魔法,而是语法糖

Async/Await 并没有引入全新的底层机制,它本质上是 Generator 函数 + Promise + 自动执行器 的语法糖。

要理解它,必须理解 Generator(生成器)  的核心能力:暂停与恢复

1. Generator:交出执行权

Generator 函数(function*)通过 yield 关键字,可以让函数在执行过程中暂停,将 CPU 控制权交还给外部,并在未来某个时刻从断点处恢复执行。

JavaScript

function* generatorFn() {
    console.log('Start');
    // 1. 函数执行到这里暂停,交出控制权,并返回 'Hello'
    const result = yield 'Hello'; 
    // 3. 外部调用 next(val) 后,函数从这里恢复,result 接收外部传入的值
    console.log('Resumed with:', result); 
}

const iterator = generatorFn();
const first = iterator.next(); // 输出: Start, first.value = 'Hello'
// 2. 这里可以做任何异步操作...
iterator.next('World');        // 输出: Resumed with: World

2. Async/Await 的实现公式

如果我们将 Generator 和 Promise 结合起来,就得到了 Async/Await 的雏形:

  1. 暂停:遇到 await (即 yield),函数暂停执行。
  2. 等待:await 后面通常跟着一个 Promise(异步状态容器)。
  3. 恢复:当 Promise 状态变为 Resolved,自动执行器调用 next(data),将结果传回函数内部,代码继续向下执行。

公式总结
async function ≈ function* + 自动执行器(自动处理 yield 和 next)

三、 实战:从错误示范到最佳实践

基于大家提供的素材,我们来看看在浏览器和 Node.js 环境下,如何正确使用 Async/Await(包含对原始素材中错误的修正)。

场景一:浏览器端 Fetch 请求

原始素材中直接 console.log(res) 是拿不到数据的,因为 fetch 返回的 Response 对象解析 JSON 也是异步的。

最佳实践:

Html

<script>
// ES8 async 修饰函数
const main = async () => {
    try {
        console.log('开始请求...');
        
        // 1. await 等待 fetch 完成,拿到响应头
        // 这里的 await 相当于暂停函数,直到网络请求返回
        const response = await fetch('https://api.github.com/users/shunwuyu/repos');
        
        // 2. 注意!解析 JSON 也是异步操作,必须再次 await
        const data = await response.json();
        
        console.log('数据获取成功:', data);
    } catch (error) {
        // 同步写法的最大优势:可以直接用 try-catch 捕获异步错误
        console.error('请求失败:', error);
    }
}
main();
</script>

场景二:Node.js 文件读取

在现代 Node.js 中,我们常用 fs/promises。

修正后的最佳实践:

JavaScript

import fs from 'fs/promises'; // 引入返回 Promise 的 fs 模块
import path from 'path';

const main = async () => {
    const filePath = './1.html';
    
    try {
        // 像写同步代码一样读取文件
        // 甚至不需要回调函数,也不需要 .then
        const html = await fs.readFile(filePath, 'utf-8');
        
        console.log('文件读取成功,长度:', html.length);
        console.log(html.substring(0, 50) + '...'); // 打印前50个字符
        
    } catch (err) {
        console.error('文件读取出错:', err);
    }
}

main();

四、 总结

Async/Await 的出现,标志着 JavaScript 异步编程的成熟。

  1. 它利用 Generator 实现了函数的暂停与恢复。
  2. 它利用 Promise 封装了异步操作的状态。
  3. 它通过 自动执行 机制,让我们能以符合直觉的线性逻辑编写复杂的异步代码。

掌握了 Async/Await,不仅仅是掌握了一个关键字,更是掌握了 JavaScript 协程控制的精髓。拒绝回调地狱,从今天开始。


昨天以前首页

别再折磨自己了!放弃 Redux 后,我用 Zustand + TS 爽到起飞

作者 NEXT06
2026年1月16日 13:42

前言:Redux 真的太难了...

作为一个刚入坑 React 不久的小白,我最近真的被状态管理搞得头皮发麻

跟着教程学 Redux,一会儿 Action,一会儿 Reducer,一会儿又是 Selector... 我只是想存个数字,却要写一堆模板代码,文件切来切去,人都绕晕了。直到昨天,我在社区看到大佬安利 Zustand,号称只有 1KB,而且不用包组件,不用写 Provider。

我不信邪试了一下... 哇!这也太香了吧!  

它写起来就像原生 JS 一样简单粗暴,配合 TypeScript 的智能提示,简直是为我们这种“手残党”量身定做的!今天就迫不及待把我的学习笔记(源码)分享给大家,希望能帮到同样迷茫的小伙伴!


第一关:从最简单的计数器开始

以前用 Redux 写个计数器要建好几个文件,用 Zustand 居然只要一个函数就搞定?

TypeScript

import { create } from 'zustand';

interface CounterState {
  count: number;
  increment: () => void;
  decrement: () => void;
  reset: () => void;
}

// create 后面接个泛型 <CounterState>,TS 马上就知道里面有什么
export const useCounterStore = create<CounterState>()((set, get) => ({
  // 状态直接列出来,清晰明了!
  n: 1, // 虽然接口里没定义这个,先放着(小声bb)
  count: 0,
  
  // 👇 这里我要自我检讨一下!
  // 为了省事我用了 any... 大佬们轻喷 
  // set((state: any) => ...) 
  // 其实是因为我刚学 TS,有时候类型报错搞不定就用 any 大法保平安
  // 大家千万别学我,后面我会改进的!
  increment: () => set((state: any) => ({ count: state.count + 1 })),
  
  decrement: () => set((state: any) => ({ count: state.count - 1 })),
  
  // 这种直接重置的写法太舒服了,不用深拷贝什么的
  reset: () => set({ count: 0 })
}));

小白心得
虽然代码里那一坨 any 有点辣眼睛,但你们看这个逻辑!没有 switch-case,没有 dispatch,就是简单的函数调用!这才是人类该写的代码啊!


第二关:Todo List + 持久化魔法

接下来的需求是做一个待办事项列表。这里我发现 Zustand 有个超级厉害的中间件叫 persist。

以前我要把数据存到 localStorage,得在 useEffect 里写好几行。现在?只要配置一行代码!  刷新页面数据居然真的还在,当时我就震惊了!😲

TypeScript

import { create } from 'zustand';
import { persist } from 'zustand/middleware';

// 先定义清楚我们的 Todo 长什么样,TS 的好处体现出来了
export interface Todo {
    id: number,
    title: string,
    completed: boolean,
}

export interface TodoState {
    todos: Todo[],
    addTodo: (title: string) => void,
    removeTodo: (id: number) => void,
    toggleTodo: (id: number) => void,
}

// 这里的 <TodoState> 就像给代码装了导航仪
// 在写下面的 set 函数时,它会自动提示 todos 属性,太爽了
export const useTodoStore = create<TodoState>()(
  persist(
    (set, get) => ({
      todos: [],
      
      addTodo: (text: string) => 
        set((state) => ({
          // 这里的 ...state.todos 是不可变数据的写法
          // 虽然有点绕,但为了 React 能更新视图,我忍了!
          todos: [...state.todos, { 
            id: + Date.now(), 
            title: text,  
            completed: false,
          }]
        })),
        
      toggleTodo: (id: number) => 
        set((state) => ({
          todos: state.todos.map((todo) => 
            todo.id === id ? 
            {...todo, completed: !todo.completed} // 反转状态
            : todo
          )
        })),
        
      removeTodo: (id: number) => 
        set((state) => ({
          todos: state.todos.filter(todo => todo.id !== id)
        })),
    }),
    {
      name: 'todos', // 👇 见证奇迹的时刻!只要这一行,自动存 LocalStorage
    }
  )
)

真香时刻
只要加上 persist 和 { name: 'todos' },剩下的脏活累活 Zustand 全包了。这体验,简直是从原始社会直接跨入现代文明!🌆


第三关:用户登录 & 接口规范

最后是用户模块。以前写 JS 的时候,经常不知道 user 对象里到底有 username 还是 userName,拼错单词 debug 半天。

现在配合 TS 的 interface,把规矩立在前面:

TypeScript

import { create } from 'zustand';
import { persist } from 'zustand/middleware'; 

// 定义用户长啥样
export interface User {
    id: number,
    username: string,
    avatar?: string, // ? 表示头像可有可无
}

interface UserState {
  isLoggin: boolean; // 虽然这里我想写 isLoggedIn,但不小心拼错了...
  login: (user: { username: string; password: string }) => void;
  logout: () => void; 
  user: User | null;
}

export const useUserStore = create<UserState>()(
  persist(
    (set) => ({
      isLoggin: false,
      // 登录逻辑简直到离谱,一行代码搞定状态切换
      login: (user) => set({ isLoggin: true, user: null }), 
      logut: () => set({ isLoggin: false, user: null }),
      user: null,
    }),
    {
      name: 'user',
    }
  )
)

TS 初体验总结
虽然定义 interface User 和 UserState 确实要多写几行代码,但在TRAE里写代码时,那种敲一个点 . 就能自动弹出属性的感觉,真的太有安全感了!  再也不怕因为手滑写错单词而报错了。


结尾碎碎念

作为一个前端萌新,我觉得 Zustand + TypeScript 简直是绝配!

  • Zustand 负责简单(拒绝样板代码)。
  • TypeScript 负责安全(拒绝低级错误)。

如果你也像我一样被 Redux 折磨得痛不欲生,赶紧去试试 Zustand 吧!入股不亏!


❌
❌