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

📍 题目背景

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

【题目描述】 给你一个包含 n 个整数的数组 nums,判断 nums 中是否存在三个元素 abc ,使得 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数之和"的问题你都可以自信应对!

相关推荐
喜欢打篮球的普通人1 小时前
Trition程序编写:从“Hello CUDA“到“Hello Triton“:向量加法背后的编译黑魔法
开发语言·后端·rust
code_std1 小时前
java WebSocket 使用
java·开发语言·websocket
大鱼>1 小时前
AI+货物追踪:贵重物品智能追踪系统
人工智能·深度学习·算法·机器学习
大鱼>1 小时前
AI+货物追踪:集装箱智能追踪系统
人工智能·深度学习·算法·机器学习
gihigo19981 小时前
FastSLAM2.0(精度优于1.0)MATLAB 实现
开发语言·matlab
Yolo566Q1 小时前
Noah-MP陆面过程模型建模方法与站点、区域模拟实践技术应用
开发语言·python
落叶-IT1 小时前
Java Scanner 类精讲:控制台交互
java·开发语言
z小猫不吃鱼1 小时前
模型剪枝经典论文精读:NISP: Pruning Networks using Neuron Importance Score Propagation
算法·机器学习·剪枝
researcher-Jiang2 小时前
栈的模板类与基本应用(还差栈混洗)
算法
chh5632 小时前
C++--string
java·开发语言·网络·c++·学习