算法面试考查的不只是能否写出代码,还包括能否澄清条件、从朴素解法中发现瓶颈、说明优化依据,并准确分析时间与空间复杂度。只背题目答案通常很难迁移:题目稍微改变输入形式或约束,原来的代码就不再适用。

更稳定的复习方式是记住一组解题模式。看到“有序”“连续区间”“重复状态”“最少步数”等信号时,先缩小候选算法,再用不变量或状态定义验证选择是否正确。本文用 C# 整理常见模式,代码以容易在面试现场复现为目标,不追求技巧性最短写法。

1. 🧭 面试中的解题顺序

拿到题目后,可以按下面的顺序推进:

  1. 澄清输入:数据规模多大,是否有序,是否包含重复值、负数或空输入,能否修改原数据。
  2. 确认输出:要返回值、索引、数量、路径,还是只判断是否存在;无解时返回什么。
  3. 给出朴素解法:先保证正确,再指出重复计算、重复扫描或多余搜索在哪里。
  4. 识别结构:题目是否具有有序性、连续性、单调性、无后效性或重复子问题。
  5. 说明正确性:解释循环不变量、搜索空间为何能缩小,或状态转移为何覆盖所有选择。
  6. 验证边界:空集合、单元素、全部相同、答案不存在、整数溢出和递归深度。
  7. 分析复杂度:以实际代码为准,同时说明额外空间是否包含返回结果。

1.1 常见复杂度的直觉

复杂度常见来源面试中的判断
O(1)数组索引、哈希表平均查询输入增长时操作次数基本不变
O(log n)二分查找、平衡树操作每一步排除固定比例的数据
O(n)单次扫描、哈希预处理每个元素只处理常数次
O(n log n)高效比较排序、分治通常可以接受中等规模输入
O(n²)两层枚举、比较所有元素对数据量较大时需要寻找结构性优化
O(2ⁿ)、O(n!)子集、排列等穷举搜索必须依赖较小输入、剪枝或状态压缩

大 O 描述的是增长趋势,不是精确运行时间。哈希查询的 O(1) 通常指平均复杂度,排序的 O(n log n) 也不表示所有排序算法和输入都具有相同成本。回答时应把结论和具体实现绑定起来。

2. 🔑 哈希表:用空间换查询时间

识别信号: 题目需要快速判断元素是否出现、统计频次、去重,或建立“值到索引”的映射。朴素解法中如果存在“对每个元素再扫描一次寻找匹配项”,通常可以考虑哈希表。

核心思路: 扫描到当前位置时,哈希表保存已经处理过的信息。查找当前元素需要的互补值,就能把两层枚举压缩成一次扫描。

哈希表求两数之和的步骤数组为二、七、十一、十五,目标为九。字典先为空,随后保存二到零,处理七时查询到互补值二,最后返回索引零和一。TWO SUM · TARGET = 9输入数组 nums271115i = 0i = 1i = 2i = 3字典 seen: value → index尚未保存任何值循环不变量:字典只包含当前位置左侧的元素。
从空字典开始字典记录已经访问过的值及其索引。
1 / 4
哈希表:两数之和输入数组与字典状态始终并列;每次先查询互补值,再写入当前值。
两数之和通过字典保存已访问的数字,在处理七时查询到互补值二并返回索引零和一。

下面是一遍扫描的两数之和模板:

static int[] TwoSum(int[] numbers, int target)
{
    var indexByValue = new Dictionary<int, int>();

    for (int i = 0; i < numbers.Length; i++)
    {
        int complement = target - numbers[i];

        if (indexByValue.TryGetValue(complement, out int otherIndex))
        {
            return new[] { otherIndex, i };
        }

        indexByValue[numbers[i]] = i;
    }

    return Array.Empty<int>();
}

先查询、后写入很重要。这样不会把当前元素与自身重复使用,同时仍能正确处理两个值相同的情况,例如 [3, 3] 和目标值 6

复杂度: 平均时间 O(n),额外空间 O(n)。发生大量哈希冲突时,操作成本可能退化,因此 O(1) 查询不是无条件保证。

常见错误:

  • 只用 HashSet<int> 记录出现过的值,却忘记题目要求返回原始索引。
  • 使用 Add 保存重复键导致异常,而题目本身允许重复元素。
  • 对可变对象计算哈希后又修改参与相等比较的字段,导致集合无法正确找到该键。

常见追问: 如果不允许额外 O(n) 空间,可以先排序再使用双指针,把额外空间降低到取决于排序实现的水平,但原始索引需要额外保存,时间复杂度也会变成 O(n log n)。

3. ↔️ 双指针:利用顺序缩小搜索空间

识别信号: 输入已经有序,或者题目要求原地处理数组;需要寻找元素对、删除重复项、合并区间,或判断链表是否存在环。

双指针常见两种形式:

  • 相向指针:从左右两端开始,根据当前结果排除一侧。
  • 同向指针:快指针负责扫描,慢指针维护已经处理好的区域。
双指针在有序数组中寻找目标和的步骤数组为一、二、四、七、十一,目标为九。初始一加十一过大,右指针左移后,一加七过小,再移动左指针,二加七等于九。SORTED ARRAY · TARGET = 9[1, 2, 4, 7, 11]124711L = 0R = 41 + 11 = 12 > 9和偏大:右端的 11 不可能成为答案有序性保证:固定左端时,继续使用更大的右端只会让和更大。
两端开始比较left = 0、right = 4;1 + 11 = 12,大于目标 9。
1 / 5
双指针:有序数组两数之和比较结果决定移动哪一侧指针;淡化的格子表示已经被证明不可能参与答案的范围。
有序数组一、二、四、七、十一中,用左右指针通过比较和与目标九缩小搜索范围,最后找到二和七。

下面在有序数组中判断是否存在和为目标值的元素对:

static bool HasPairWithSum(int[] sortedNumbers, int target)
{
    int left = 0;
    int right = sortedNumbers.Length - 1;

    while (left < right)
    {
        long sum = (long)sortedNumbers[left] + sortedNumbers[right];

        if (sum == target)
        {
            return true;
        }

        if (sum < target)
        {
            left++;
        }
        else
        {
            right--;
        }
    }

    return false;
}

这里的不变量是:当前答案如果存在,一定位于闭区间 [left, right]。由于数组有序,当两端之和偏小时,保留较小的左端点不可能与区间内其他元素得到更大的和,因此可以安全移动 left;偏大时同理移动 right

复杂度: 时间 O(n),额外空间 O(1)。如果输入尚未排序且允许排序,总时间通常为 O(n log n)。

常见错误:

  • 在无序数组上直接根据和的大小移动指针,此时没有顺序依据,可能跳过答案。
  • 循环写成 left <= right,让同一个元素被使用两次。
  • 两个 int 相加后才转换为 long,整数可能已经溢出。应在加法前转换其中一个操作数。

常见追问: 如果要求保留原始索引,排序时需要把值和索引一起保存;如果要求原地删除重复项,则可以改用同向快慢指针维护有效前缀。

4. 🪟 滑动窗口:维护连续区间

识别信号: 题目讨论连续子数组或子串,并要求最长、最短、固定长度统计,或者满足某种频次和约束的区间。

核心思路: 右边界负责把新元素加入窗口,左边界在窗口不满足条件时向右收缩。关键不是“有两个指针”,而是窗口状态能够随着边界移动进行增量更新,不必每次重新扫描整个区间。

滑动窗口寻找最长无重复子串的步骤字符串是 A、B、C、B、E。右指针扩张并记录频次,第二个 B 形成重复后,左指针越过旧 B,最后窗口 C、B、E 的长度为三。STRING = ABCBE · LONGEST UNIQUE SUBSTRING字符与索引ABCBEL = R = 01234窗口 [A]freqA1其余字符:0最长长度 = 1
右边界读入 A窗口 [A] 合法;freq[A] = 1,最长长度更新为 1。
1 / 6
滑动窗口:最长无重复子串窗口范围、左右边界与频次表同步出现;重复不只依赖颜色,而以计数和“重复”标记说明。
字符串 ABCBE 的滑动窗口随着右边界扩张,在第二个 B 出现重复后移动 left 越过旧 B,并维护最长无重复长度三。

下面求不含重复字符的最长子串长度:

static int LengthOfLongestUniqueSubstring(string text)
{
    var lastIndex = new Dictionary<char, int>();
    int left = 0;
    int best = 0;

    for (int right = 0; right < text.Length; right++)
    {
        char current = text[right];

        if (lastIndex.TryGetValue(current, out int previous))
        {
            left = Math.Max(left, previous + 1);
        }

        lastIndex[current] = right;
        best = Math.Max(best, right - left + 1);
    }

    return best;
}

窗口 [left, right] 始终不包含重复字符。left 只能向右移动,不能因为遇到较早位置的重复字符而后退,所以需要使用 Math.Max

复杂度: 时间 O(n),额外空间 O(k),其中 k 是窗口内可能出现的字符种类数。

常见错误:

  • 更新答案时忘记窗口长度是 right - left + 1
  • 删除或覆盖频次后没有正确判断窗口是否重新满足条件。
  • 把所有连续区间问题都套用滑动窗口。比如寻找“和至少为目标值的最短子数组”时,若数组包含负数,加入新元素后窗口和不再单调变化,简单的左右收缩策略可能失效。

常见追问: 固定长度窗口不需要 while 收缩,只需在加入右端元素后移除离开窗口的左端元素;涉及多种字符频次时,可以维护 valid 计数,避免每次比较完整字典。

5. 🔍 二分查找:在单调空间中排除一半

识别信号: 数据有序,或者存在一个单调判断条件:当某个值可行时,更大或更小的一整侧也必然可行。后者常用于“最小可行值”“最大允许值”等答案空间搜索。

核心思路: 每次通过中点判断,确定答案只能位于哪一半。写二分查找时必须先明确区间含义,以及循环结束后返回的变量代表什么。

二分查找第一个大于等于六的位置有序数组一、三、五、七、九、十一。步骤展示左右边界、中点、候选答案和被排除区间。SORTED ARRAY / TARGET = 6left = 0mid = 2right = 5排除 [0..2]:5 < 61357911012 / mid345answer = 未设置left 向右移动到 3
检查中点 55 小于 6,位置 0 到 2 不可能成为答案,left 移到 3。
1 / 4
二分查找:第一个 >= 6每次命中条件后保留候选答案,并继续向左收缩。
在有序数组中逐步收缩搜索范围,找到第一个大于等于六的值七。

下面使用闭区间 [left, right] 查找第一个大于等于目标值的位置;不存在时返回数组长度:

static int LowerBound(int[] sortedNumbers, int target)
{
    int left = 0;
    int right = sortedNumbers.Length - 1;
    int answer = sortedNumbers.Length;

    while (left <= right)
    {
        int middle = left + (right - left) / 2;

        if (sortedNumbers[middle] >= target)
        {
            answer = middle;
            right = middle - 1;
        }
        else
        {
            left = middle + 1;
        }
    }

    return answer;
}

sortedNumbers[middle] >= target 时,中点可能是答案,但左侧还可能存在更早的位置,因此先记录中点,再继续搜索左半区间。

复杂度: 时间 O(log n),额外空间 O(1)。

常见错误:

  • 循环使用 left <= right,却只把边界更新为 middle,导致区间不缩小并进入死循环。
  • 找到一个目标值就立即返回,忽略题目要求第一个或最后一个位置。
  • 在答案空间二分中没有证明判定函数具有单调性。

常见追问: “最小化最大值”类题目经常可以二分答案。此时数组不一定有序,但“给定上限是否可行”的布尔结果必须随候选值单调变化。

6. 📚 栈、队列与单调结构

识别信号:

  • 最近加入的状态最先处理、括号配对、撤销操作:栈。
  • 按到达顺序或按层扩展:队列。
  • 查找左侧或右侧第一个更大、更小元素:单调栈。

单调栈保存尚未找到答案的元素索引,并维持栈内值的单调关系。

单调递减栈处理当前值七展示九、四、二依次入栈,七到来时弹出二和四并更新答案,最后压入七。INPUT / NEXT GREATER ELEMENT已处理:9递减栈(栈顶在下)9已压入9topanswer:9 → 未结算空栈直接接收 9
压入 9递减栈为空,9 直接入栈,等待右侧第一个更大值。
1 / 5
单调栈:下一个更大元素栈中保留尚未找到右侧更大值的元素,当前值负责结算连续较小的栈顶。
递减单调栈依次处理九、四、二,在七到来时结算二和四并压入七。

下面求每个位置右侧第一个更大元素的索引,不存在时保留 -1

static int[] NextGreaterIndexes(int[] numbers)
{
    int[] answer = new int[numbers.Length];
    Array.Fill(answer, -1);

    var stack = new Stack<int>();

    for (int i = 0; i < numbers.Length; i++)
    {
        while (stack.Count > 0 && numbers[i] > numbers[stack.Peek()])
        {
            int index = stack.Pop();
            answer[index] = i;
        }

        stack.Push(i);
    }

    return answer;
}

栈中保存的是索引,因为答案通常需要位置或距离。每个索引最多入栈一次、出栈一次,虽然代码存在嵌套循环,总操作次数仍然是线性的。

复杂度: 时间 O(n),额外空间 O(n)。

常见错误:

  • 只看到嵌套 while 就判断复杂度为 O(n²),没有分析每个元素的总入栈和出栈次数。
  • 混淆“严格更大”和“大于等于”,导致重复值处理错误。
  • 使用 List<T>.RemoveAt(0) 模拟队列,使每次出队都移动后续元素。C# 中应使用 Queue<T>

常见追问: 如果要求下一个更大元素的距离,可以保存 i - index;处理循环数组时,可以遍历两倍长度并用取模访问,但只在第一轮压入索引。

7. 🌲 DFS 与 BFS:系统遍历状态空间

识别信号: 输入天然构成树、图、网格或状态转换关系,需要判断连通性、遍历全部节点、查找路径,或者计算无权图中的最少步数。

  • **DFS(深度优先搜索)**沿一条路径尽量深入,再返回处理其他分支,适合连通性、子树计算和需要回溯的搜索。
  • **BFS(广度优先搜索)**按距离逐层扩展。在每条边代价相同的无权图中,节点第一次被访问时的层数就是最短步数。
DFS 栈与 BFS 队列在同一图上的并行遍历图包含 A、B、C、D、E、F 六个结点。每一帧列出 DFS 栈前沿、BFS 队列前沿和各自已访问结点。SAME GRAPH / TWO FRONTIERSABCDEFDFS 栈 frontier: [A](top)visited: -BFS 队列 frontier: [A](front)visited: -
从 A 初始化DFS 将 A 放入栈,BFS 将 A 放入队列;两者的 frontier 都从 A 开始。
1 / 7
DFS 与 BFS:前沿的差异两种遍历共享 visited 规则;差异来自 frontier 采用栈或队列。
同一张图上并行展示深度优先搜索的栈前沿和广度优先搜索的队列前沿。

下面给出邻接表上的 DFS 和 BFS 距离模板:

static void Dfs(int node, List<int>[] graph, bool[] visited)
{
    if (visited[node])
    {
        return;
    }

    visited[node] = true;

    foreach (int next in graph[node])
    {
        Dfs(next, graph, visited);
    }
}

static int[] BfsDistances(int start, List<int>[] graph)
{
    int[] distance = new int[graph.Length];
    Array.Fill(distance, -1);

    var queue = new Queue<int>();
    queue.Enqueue(start);
    distance[start] = 0;

    while (queue.Count > 0)
    {
        int node = queue.Dequeue();

        foreach (int next in graph[node])
        {
            if (distance[next] != -1)
            {
                continue;
            }

            distance[next] = distance[node] + 1;
            queue.Enqueue(next);
        }
    }

    return distance;
}

访问标记应在节点入队时设置,而不是出队时设置,否则同一节点可能被多个前驱重复加入队列。

复杂度: 使用邻接表时,DFS 和 BFS 的时间都是 O(V + E),额外空间为 O(V),其中 V 是节点数,E 是边数。递归 DFS 的调用栈也要计入空间。

常见错误:

  • 在可能有环的图中不记录访问状态,导致无限遍历。
  • 把树的“父子关系天然无环”假设直接带到一般图。
  • 声称 BFS 能解决所有最短路径问题。只有边权相同或视为无权时,普通 BFS 才直接得到最短步数。
  • 在节点很多或路径很深时使用递归 DFS,没有考虑栈溢出风险。

常见追问: 网格搜索可以把每个坐标视为节点,通过方向数组生成相邻位置;如果所有边权为 0 或 1,可以进一步讨论 0-1 BFS,但它已超出普通队列 BFS 的模板范围。

8. 🧩 回溯:枚举选择并恢复现场

识别信号: 题目要求列出所有排列、组合、子集或满足约束的方案,输入规模较小,答案本身可能呈指数增长。

回溯可以抽象成三个动作:做选择、递归探索、撤销选择。递归参数描述当前搜索位置,路径保存已经做出的选择,剪枝排除不可能完成的分支。

从一到四选择两个数的回溯过程决策树展示当前路径、已记录组合和不能继续的剪枝分支。CANDIDATES 1..4 / PICK 2CURRENT PATH[]choose123NEXT RECURSIONpath = [1]
选择起点从 1 开始,将 1 放入当前路径。
1 / 6
回溯:从 1..4 选择两个数当前路径会随递归进入和撤销而变化;虚线分支明确表示无法完成组合的剪枝。
回溯算法通过选择、递归、记录、撤销与剪枝枚举组合。

下面生成从 1..n 中选择 k 个数的全部组合:

static List<List<int>> Combine(int n, int k)
{
    var result = new List<List<int>>();
    var path = new List<int>();

    void Search(int start)
    {
        if (path.Count == k)
        {
            result.Add(new List<int>(path));
            return;
        }

        int remaining = k - path.Count;
        int lastStart = n - remaining + 1;

        for (int value = start; value <= lastStart; value++)
        {
            path.Add(value);
            Search(value + 1);
            path.RemoveAt(path.Count - 1);
        }
    }

    if (k >= 0 && k <= n)
    {
        Search(1);
    }

    return result;
}

结果中必须使用 new List<int>(path) 保存路径副本。如果直接保存同一个 path 引用,后续撤销操作会改变已经加入结果的内容。

复杂度: 生成组合的输出规模为 C(n, k),复制每个长度为 k 的结果需要 O(k),因此时间可写为 O(C(n, k) × k)。不计算返回结果时,递归路径空间为 O(k)。

常见错误:

  • 递归返回后忘记撤销选择,使不同分支共享错误状态。
  • 排列问题没有使用 used 数组,导致同一元素被重复选择。
  • 输入含重复值时只排序却没有在同一递归层跳过重复候选。
  • 只说“剪枝后很快”,却不说明最坏情况仍可能是指数级。

常见追问: 组合使用递增的 start 避免顺序不同但元素相同的重复结果;排列通常从所有未使用元素中选择,状态和去重规则不同。

9. 🎯 贪心:做出可证明的局部选择

识别信号: 题目要求最大数量、最少资源或最优安排,并且做出当前选择后,可以证明某个最优解仍然存在,不需要回头修改之前的决定。

“每次选当前看起来最好”只是策略描述,不是正确性证明。贪心通常需要交换论证:证明任意最优解都可以把它的第一个选择替换成贪心选择,且结果不会变差。

按结束时间选择不重叠半开区间时间轴展示排序、接受、拒绝重叠和接受端点相接区间的贪心过程。SORT BY END TIME (ASC)INPUT[5,7) [2,4) [1,3) [3,5)ORDERED[1,3)[2,4)[3,5)[5,7)scan from the earliest end
按结束时间排序将半开区间按 end 从小到大排列,优先留下更多空间。
1 / 4
贪心:最多不重叠半开区间区间采用 [start, end) 语义,因此 [1, 3) 和 [3, 5) 可以同时被接受。
按结束时间排序,接受最早结束的区间并拒绝与已选区间重叠的候选。

下面选择最多数量的互不重叠半开区间 [Start, End)

static int MaxNonOverlappingIntervals((int Start, int End)[] intervals)
{
    Array.Sort(intervals, (a, b) => a.End.CompareTo(b.End));

    int count = 0;
    int previousEnd = int.MinValue;

    foreach ((int Start, int End) interval in intervals)
    {
        if (interval.Start < previousEnd)
        {
            continue;
        }

        count++;
        previousEnd = interval.End;
    }

    return count;
}

优先选择结束最早的区间,会为后续区间留下不小于其他选择的可用空间。任意最优方案的第一个区间都可以替换为结束更早的区间,而不会减少后续可选数量。

复杂度: 排序时间 O(n log n),扫描时间 O(n)。额外空间取决于运行库排序实现;代码还会修改输入数组的顺序。

常见错误:

  • 凭直觉选择最短区间或最早开始区间,却没有证明该规则不会破坏全局最优。
  • 忽略区间是闭区间还是半开区间,导致端点相接时的判断不同。
  • 题目要求输出具体方案,却只记录数量。

常见追问: 如果局部选择会影响后续状态,且无法通过交换论证保证最优,通常需要考虑动态规划;如果区间带权重,“结束最早”也不再直接保证总权重最大。

10. 🧠 动态规划:复用重复子问题

识别信号: 问题包含重复子问题,需要求最大值、最小值、方案数或可行性;当前决策依赖有限的历史状态,并且最优解能够由规模更小的最优解构成。

写动态规划前先回答五个问题:

  1. dp[i] 或当前变量表示什么。
  2. 当前状态可以从哪些旧状态转移而来。
  3. 初始状态是什么。
  4. 状态必须按什么顺序计算。
  5. 最终答案位于哪个状态。
数组二七九三一的不相邻最大和动态规划动态规划表显示状态定义、基础状态、跳过和选择当前元素的依赖,以及最终答案十二。STATE DEFINITIONdp[i] = max sum using nums[0..i], no adjacent picksnums27931dpdp[0]dp[1]dp[i]one state per prefix, read left to right
定义状态dp[i] 表示考虑前 i+1 个元素时可取得的最大和。
1 / 6
动态规划:不相邻元素最大和每一格 dp[i] 都保留到当前位置的最优答案;箭头分别标示 skip 与 take 依赖。
数组二、七、九、三、一通过 skip 与 take 状态转移得到最大不相邻和十二。

下面求不能选择相邻元素时的最大元素和。允许一个元素都不选,因此全为负数时返回 0

static int MaxNonAdjacentSum(int[] numbers)
{
    int bestBeforePrevious = 0;
    int bestBeforeCurrent = 0;

    foreach (int number in numbers)
    {
        int skipCurrent = bestBeforeCurrent;
        int takeCurrent = bestBeforePrevious + number;
        int current = Math.Max(skipCurrent, takeCurrent);

        bestBeforePrevious = bestBeforeCurrent;
        bestBeforeCurrent = current;
    }

    return bestBeforeCurrent;
}

状态定义为“处理完当前位置之前的元素后,能够取得的最大和”。对当前元素只有两种互斥选择:跳过它,答案保持为前一个状态;选择它,就只能加到隔一个位置之前的最优结果上。

复杂度: 时间 O(n),额外空间 O(1)。如果需要恢复具体选择了哪些元素,则通常要保存完整状态或额外的决策信息。

常见错误:

  • 没有先定义状态含义,直接凭样例猜转移公式。
  • 一维状态压缩时过早覆盖仍会被后续计算使用的旧值。
  • 初始化只适用于正数输入,却没有确认题目是否允许负数或空选择。
  • 把所有最优化问题都归为动态规划,没有检查状态数量是否可接受。

常见追问: 自顶向下记忆化搜索和自底向上动态规划通常解决同一组状态。前者更接近递归定义并可能只访问必要状态,后者避免递归调用并更容易做空间压缩。

11. 📋 算法选择速查表

题目特征优先考虑必要前提或检查点
快速判断存在、计数、去重哈希表接受额外空间;明确键和相等语义
有序数组中寻找元素对相向双指针移动指针必须有有序性依据
原地压缩、去重、分区同向双指针慢指针维护已处理区域
连续子数组或子串的最长、最短滑动窗口窗口条件能随边界增量维护
有序数据查找边界二分查找明确区间定义和目标边界
最小可行值、最大允许值答案空间二分判定结果随候选值单调变化
最近未匹配状态、括号、撤销后进先出的处理顺序
右侧第一个更大或更小元素单调栈明确严格或非严格比较
树、图、网格的连通性DFS 或 BFS一般图需要访问标记
无权图最少步数BFS每条边代价相同
枚举排列、组合、子集回溯输入规模允许指数级搜索
区间安排等局部选择问题贪心必须能证明局部选择不损失最优性
重复子问题的最值、计数、可行性动态规划状态和转移规模可接受

同一道题可能有多种解法。例如两数之和可以使用哈希表,也可以在排序后使用双指针。面试中不必假装只有一个答案,而应根据是否允许修改输入、是否需要原始索引、空间限制和数据规模解释取舍。

12. 复习顺序与临场检查

建议按依赖关系复习,而不是从困难题目开始随机刷题:

  1. 先熟悉数组、字符串、链表以及 O(1)、O(n)、O(log n)、O(n log n) 的判断。
  2. 掌握哈希表和双指针,练习把 O(n²) 朴素解法优化为 O(n) 或 O(n log n)。
  3. 掌握滑动窗口与二分边界,重点检查循环不变量和索引更新。
  4. 掌握栈、队列、树与图的 DFS/BFS,形成统一的访问状态模板。
  5. 再学习回溯,明确路径、选择列表、终止条件和撤销动作。
  6. 最后集中训练贪心证明和动态规划状态设计,避免只记结论或代码。

提交答案前,可以快速检查:空输入是否处理、循环是否必然推进、数组索引是否越界、重复值语义是否正确、整数是否可能溢出、递归是否可能过深,以及复杂度是否和实际实现一致。

真正需要记住的不是每道题的完整代码,而是模板背后的依据:哈希表保存什么信息,指针为什么可以移动,窗口为什么可以收缩,二分为什么能够排除一半,DFS/BFS 如何避免重复访问,回溯如何恢复状态,贪心如何证明,动态规划的状态又代表什么。能把这些问题说明白,代码通常只是最后一步。