【算法日记18】LeetCode 15. 三数之和:排序与双指针的降维打击 (附可视化动画源码)

📍 题目背景

今天咱们来挑战算法面试中极其高频、让无数新手痛不欲生的经典大题:15. 三数之和 。 这道题是经典的"双指针"进阶题,如果用三个 for 循环暴力破解,不仅时间复杂度会飙升到 O(N\^3) 直接超时,更痛苦的是:题目要求结果中不能包含重复的三元组! 在乱序数组里去重,简直是地狱级难度。

【题目描述】 给你一个包含 n 个整数的数组 nums,判断 nums 中是否存在三个元素 a,b,c ,使得 a + b + c = 0 ?请你找出所有和为 0 且 不重复 的三元组。

【示例】

输入: nums = [-1,0,1,2,-1,-4] 输出: [[-1,-1,2],[-1,0,1]]

✨ 核心破题逻辑:降维打击 (排序 + 双指针)

既然找 3 个数字太难,不如我们把它降维:固定 1 个数,再去用"左右指针"找另外 2 个数!

破题四步走:

  1. 先排个序(核心灵魂):这是降维打击的基础!排序后,相同的数字就会挨在一起,这让我们后面能够轻松"去重"。而且只有数组有序了,左右指针才知道该往哪边走。

  2. 固定"大哥" (i) :用一个 for 循环从左到右遍历数组,每次选定一个 nums[i] 作为"大哥"。

  3. 派出左右"小弟" (left, right) :在"大哥"右边的区间里,派 left 站在最左边,right 站在最右边,开始向中间夹击寻找:

    • 如果 总和 > 0:说明数字太大了,右小弟 right 往左走一步找更小的数。

    • 如果 总和 < 0:说明数字太小了,左小弟 left 往右走一步找更大的数。

    • 如果 总和 == 0:恭喜,找到一组答案!记录下来!

  4. 疯狂去重(精髓):遇到和上一次完全相同的数字,直接跳过,绝不干重复的活儿!

💣 史上最烦人的陷阱:去重!

这道题 80% 的报错都来自于去重没写好。比如数组排序后是 [-1, -1, -1, 0, 1, 1],如果不去重,你找出来的答案全是重复的 [-1, 0, 1]。

去重必须设下"两道门禁":

  1. 大哥去重 :如果当前的 nums[i] 和上一个 nums[i-1] 是一样的,说明这个数字当大哥的组合已经被找过了,直接 continue 跳过。

  2. 小弟去重 :当左右小弟找到一组 0 后,left 要看看右边是不是双胞胎(nums[left] == nums[left+1]),right 要看看左边是不是双胞胎。如果是,果断跨过去!

💻 C++ 满分 AC 代码

掌握了上面的逻辑,这套双指针组合拳打出来极其优雅:

复制代码
#include <iostream>
#include <vector>
#include <algorithm>

using namespace std;

class Solution {
public:
    vector<vector<int>> threeSum(vector<int>& nums) {
        vector<vector<int>> res;
        
        // 🚀 第一步:排序,降维打击的基础
        sort(nums.begin(), nums.end());
        
        // 🚀 第二步:固定大哥 (nums[i])
        for(int i = 0; i < nums.size(); i++) {
            
            // 【究极优化】大哥都大于0了,后面的小弟肯定更大,绝对凑不出0,直接下班!
            if(nums[i] > 0) break; 
            
            // 【大哥去重】遇到双胞胎大哥,直接跳过
            if(i > 0 && nums[i] == nums[i-1]) continue;
            
            // 🚀 第三步:派出左右小弟开始夹击
            int left = i + 1;
            int right = nums.size() - 1;
            
            while(left < right) {
                int sum = nums[i] + nums[left] + nums[right];   
                
                if(sum == 0) {
                    // 🎉 找到一组,记录下来
                    res.push_back({nums[i], nums[left], nums[right]});
                    
                    // 【小弟去重】跨过一模一样的双胞胎小弟
                    while(left < right && nums[left] == nums[left+1]) left++;
                    while(left < right && nums[right] == nums[right-1]) right--;
                    
                    // 跨过去后,各自再往前走一步,寻找下一组可能
                    left++;
                    right--;
                }
                else if(sum < 0) {
                    left++; // 太小了,左边往右走找大点儿的
                }
                else {
                    right--; // 太大了,右边往左走找小点儿的
                }
            }
        }
        return res;
    }
};

【复杂度分析】

  • 时间复杂度:O(N\^2)。排序的时间是 O(N \\log N)。外层遍历大哥需要 O(N),内层两个小弟相向而行遍历剩下的数组也需要 O(N)。所以整体是 O(N \\log N + N\^2),也就是 O(N\^2)。

  • 空间复杂度 :O(1) 或 O(\\log N)。除了存储答案的数组,我们只用了几个指针变量。实际的空间消耗主要取决于语言底层 sort 排序算法所占用的栈空间。

🎁 独家彩蛋:双指针运行 3D 动画 (前端源码)

这道题的去重逻辑光看文字很容易晕。为了直观感受,我用 HTML + TailwindCSS + JS 写了一个轻量级的交互式动画。 大家可以直接将下面这段代码保存为 index.html 并在浏览器中打开,亲眼见证指针是怎么夹击和跳过去重的!

复制代码
<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>双指针魔法:三数之和</title>
    <script src="[https://cdn.tailwindcss.com](https://cdn.tailwindcss.com)"></script>
    <style>
        .cell-transition { transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1); }
        .highlight-i { box-shadow: 0 0 15px rgba(168, 85, 247, 0.7); border-color: #a855f7; background-color: #faf5ff; }
        .highlight-left { box-shadow: 0 0 15px rgba(59, 130, 246, 0.7); border-color: #3b82f6; background-color: #eff6ff; }
        .highlight-right { box-shadow: 0 0 15px rgba(239, 68, 68, 0.7); border-color: #ef4444; background-color: #fef2f2; }
        .highlight-found { box-shadow: 0 0 20px rgba(34, 197, 94, 0.9); border-color: #22c55e; background-color: #f0fdf4; transform: scale(1.1); z-index: 10; }
        .highlight-skip { border-color: #9ca3af; background-color: #f3f4f6; opacity: 0.7; }
    </style>
</head>
<body class="bg-gray-100 min-h-screen flex flex-col items-center justify-center p-4 font-sans text-gray-800">
    <div class="max-w-4xl w-full bg-white rounded-2xl shadow-2xl overflow-hidden">
        <div class="bg-gradient-to-r from-purple-600 to-indigo-600 text-white p-6 text-center">
            <h1 class="text-3xl font-bold mb-2">🕵️‍♂️ 左右指针魔法:三数之和 (3Sum)</h1>
            <p class="text-indigo-100">LeetCode 15. 排序 + 固定一端 + 双指针夹击</p>
        </div>
        <div class="p-8">
            <div class="bg-blue-50 border-l-4 border-blue-500 p-5 rounded-r-xl mb-10 min-h-[100px] flex items-center shadow-sm">
                <p id="message-box" class="text-lg font-medium text-blue-900 leading-relaxed">准备开始!点击"下一步"或"自动播放"观察指针是如何配合的。</p>
            </div>
            <div class="flex flex-wrap justify-center gap-4 mb-16" id="array-container"></div>
            <div class="mb-8 text-center min-h-[40px]">
                <span class="text-gray-500 font-semibold mr-2">已找到的不重复三元组:</span>
                <span id="results-box" class="text-green-600 font-bold text-xl">[]</span>
            </div>
            <div class="flex justify-center gap-4">
                <button id="btn-reset" class="px-6 py-2.5 bg-gray-200 hover:bg-gray-300 text-gray-700 font-semibold rounded-full transition-colors shadow">🔄 重置</button>
                <button id="btn-step" class="px-6 py-2.5 bg-indigo-500 hover:bg-indigo-600 text-white font-semibold rounded-full shadow-md transition-colors transform hover:scale-105">👣 下一步</button>
                <button id="btn-play" class="px-6 py-2.5 bg-green-500 hover:bg-green-600 text-white font-semibold rounded-full shadow-md transition-colors transform hover:scale-105">▶️ 自动播放</button>
            </div>
            <div class="mt-10 flex flex-wrap justify-center gap-6 text-sm font-bold text-gray-600 bg-gray-50 p-4 rounded-xl">
                <div class="flex items-center gap-2"><span class="w-4 h-4 rounded-md border-2 border-purple-500 bg-purple-100"></span> i (大哥)</div>
                <div class="flex items-center gap-2"><span class="w-4 h-4 rounded-md border-2 border-blue-500 bg-blue-100"></span> left (左小弟)</div>
                <div class="flex items-center gap-2"><span class="w-4 h-4 rounded-md border-2 border-red-500 bg-red-100"></span> right (右小弟)</div>
            </div>
        </div>
    </div>
    <script>
        const initialArray = [-1, 0, 1, 2, -1, -4];
        let currentFrameIndex = 0, isPlaying = false, playInterval, frames = [];
        function generateFrames() {
            frames = []; let original = [...initialArray];
            frames.push({ arr: original, i: -1, left: -1, right: -1, msg: "初始数组乱序,无法直接查找。第一步:先排序!" });
            let arr = [...original].sort((a, b) => a - b);
            frames.push({ arr: [...arr], i: -1, left: -1, right: -1, msg: "🔥 第一步完成!从小到大排好队,这是左右夹击和跳过重复数字的基础。" });
            let results = [];
            for (let i = 0; i < arr.length - 2; i++) {
                if (arr[i] > 0) {
                    frames.push({ arr: [...arr], i: i, left: -1, right: -1, msg: `大哥 <b>i = ${arr[i]}</b> 已经大于0,三个正数相加不可能等于0,直接提前下班!🏁` }); break;
                }
                if (i > 0 && arr[i] === arr[i - 1]) {
                    frames.push({ arr: [...arr], i: i, left: -1, right: -1, skipI: true, msg: `触发【大哥去重魔法】!跳过相同的数字。` }); continue;
                }
                let left = i + 1, right = arr.length - 1;
                frames.push({ arr: [...arr], i: i, left: left, right: right, msg: `固定大哥 <b>i = ${arr[i]}</b>,派出小弟准备夹击!` });
                while (left < right) {
                    let sum = arr[i] + arr[left] + arr[right];
                    let sumText = `(${arr[i]}) + (${arr[left]}) + (${arr[right]}) = <b>${sum}</b>`;
                    if (sum === 0) {
                        results.push(`[${arr[i]}, ${arr[left]}, ${arr[right]}]`);
                        frames.push({ arr: [...arr], i: i, left: left, right: right, found: true, results: [...results], msg: `🎉 太棒了!${sumText}。凑齐 0 啦!` });
                        let startLeft = left, startRight = right;
                        while (left < right && arr[left] === arr[left + 1]) left++;
                        while (left < right && arr[right] === arr[right - 1]) right--;
                        if (left !== startLeft || right !== startRight) {
                            frames.push({ arr: [...arr], i: i, left: left, right: right, msg: `👀 触发【小弟去重魔法】,果断跨过重复数字!` });
                        }
                        left++; right--;
                    } 
                    else if (sum < 0) {
                        frames.push({ arr: [...arr], i: i, left: left, right: right, msg: `总和:${sumText} < 0。太小啦!左小弟往右挪一步。` }); left++;
                    } 
                    else {
                        frames.push({ arr: [...arr], i: i, left: left, right: right, msg: `总和:${sumText} > 0。太大啦!右小弟往左挪一步。` }); right--;
                    }
                }
            }
            frames.push({ arr: [...arr], i: -1, left: -1, right: -1, results: [...results], msg: `🏁 搜索彻底结束!时间复杂度 O(N²)。` });
        }
        function render() {
            const frame = frames[currentFrameIndex];
            document.getElementById('message-box').innerHTML = frame.msg;
            if (frame.results) document.getElementById('results-box').innerHTML = frame.results.join(', ');
            else if (currentFrameIndex === 0) document.getElementById('results-box').innerHTML = '[]';
            const container = document.getElementById('array-container'); container.innerHTML = '';
            frame.arr.forEach((val, index) => {
                const wrapper = document.createElement('div'); wrapper.className = 'flex flex-col items-center w-14 md:w-20';
                const topLabel = document.createElement('div'); topLabel.className = 'h-6 text-sm font-bold text-purple-600 mb-1';
                topLabel.innerHTML = frame.i === index ? '↓ i' : '';
                const box = document.createElement('div');
                let boxClasses = 'w-14 h-14 md:w-20 md:h-20 flex items-center justify-center text-xl md:text-2xl font-bold border-4 rounded-xl cell-transition text-gray-800 bg-white border-gray-200 ';
                if (frame.found && (frame.i === index || frame.left === index || frame.right === index)) boxClasses += 'highlight-found';
                else if (frame.skipI && frame.i === index) boxClasses += 'highlight-skip';
                else if (frame.i === index) boxClasses += 'highlight-i';
                else if (frame.left === index) boxClasses += 'highlight-left';
                else if (frame.right === index) boxClasses += 'highlight-right';
                box.className = boxClasses; box.innerText = val;
                const bottomLabel = document.createElement('div'); bottomLabel.className = 'h-6 text-sm font-bold mt-1';
                if (frame.left === index && frame.right === index) { bottomLabel.innerHTML = '↑ L/R'; bottomLabel.classList.add('text-gray-600'); }
                else if (frame.left === index) { bottomLabel.innerHTML = '↑ left'; bottomLabel.classList.add('text-blue-600'); }
                else if (frame.right === index) { bottomLabel.innerHTML = '↑ right'; bottomLabel.classList.add('text-red-600'); }
                wrapper.appendChild(topLabel); wrapper.appendChild(box); wrapper.appendChild(bottomLabel); container.appendChild(wrapper);
            });
            document.getElementById('btn-step').disabled = currentFrameIndex >= frames.length - 1;
            document.getElementById('btn-step').className = currentFrameIndex >= frames.length - 1 ? "px-6 py-2.5 bg-gray-300 text-gray-500 font-semibold rounded-full cursor-not-allowed shadow" : "px-6 py-2.5 bg-indigo-500 hover:bg-indigo-600 text-white font-semibold rounded-full shadow-md transition-colors transform hover:scale-105";
        }
        document.getElementById('btn-step').addEventListener('click', () => { if (currentFrameIndex < frames.length - 1) { currentFrameIndex++; render(); } });
        document.getElementById('btn-reset').addEventListener('click', () => { currentFrameIndex = 0; if(isPlaying) togglePlay(); render(); });
        document.getElementById('btn-play').addEventListener('click', togglePlay);
        function togglePlay() {
            const playBtn = document.getElementById('btn-play');
            if (isPlaying) { clearInterval(playInterval); playBtn.innerHTML = '▶️ 自动播放'; playBtn.classList.replace('bg-yellow-500', 'bg-green-500'); playBtn.classList.replace('hover:bg-yellow-600', 'hover:bg-green-600'); isPlaying = false; } 
            else {
                if(currentFrameIndex >= frames.length - 1) currentFrameIndex = 0;
                playBtn.innerHTML = '⏸️ 暂停播放'; playBtn.classList.replace('bg-green-500', 'bg-yellow-500'); playBtn.classList.replace('hover:bg-green-600', 'hover:bg-yellow-600'); isPlaying = true;
                playInterval = setInterval(() => { if (currentFrameIndex < frames.length - 1) { currentFrameIndex++; render(); } else { togglePlay(); } }, 1500);
            }
        }
        generateFrames(); render();
    </script>
</body>
</html>

💡 总结: 从暴力 O(N\^3) 到优雅的 O(N\^2),中间隔着的就是排序和双指针。尤其是"去重"逻辑,充分展现了在编码时考虑边界条件(Edge Cases)的严谨性。拿下这道题,面试中遇到任何"N数之和"的问题你都可以自信应对!

相关推荐
jingli910 小时前
本地向量库索引把 C 盘塞爆 48.6G:一次 LanceDB 膨胀排查与根治实录
c语言·开发语言
言乐610 小时前
Python排名统计折线图
开发语言·python·django·virtualenv·pygame
u01110267510 小时前
图片预览越来越占内存聊聊 URL.createObjectURL 的释放时机
图像处理·深度学习·算法·ai作画
Zane199410 小时前
同样是遍历图,为什么BFS能保证找到最短路径,DFS却不行
算法
无限码力10 小时前
2026华为OD机试真题 新系统 9月27日【坚果巧克力】
算法·华为od·华为od机考·华为od机试真题·华为od机试·华为od机考真题题解·华为社招笔试真题
samforce10 小时前
叹息之墙——人类认知 AI,终将遇到的一堵同构之墙
人工智能·深度学习·算法·哲学·认知科学
艾莉丝努力练剑10 小时前
【AI大模型接入SDK】ChatSDK整体实现
网络·c++·人工智能·学习·架构
我是小白呀10 小时前
19-Temporal项目实战:将客户开通流程迁移到持久执行架构
java·开发语言·人工智能·架构·workflow
励志不掉头发的内向程序员10 小时前
【从零写一个CAD 02】画完第一条线之后:实体容器、Esc 取消,和按了没反应的键盘
开发语言·c++·qt·学习·系统架构
l1t10 小时前
编译测试兼容gzip并行压缩解压工具pigz
c++·算法