《数据结构》知识点
引言
数据结构是计算机科学中的核心概念之一,用于组织和存储数据,以便高效地访问和修改。它是算法设计的基础,也是软件开发中不可或缺的一部分。掌握数据结构不仅有助于提高程序的性能,还能帮助开发者更好地理解和解决复杂问题。
基本概念
数据与数据类型
- 数据:信息的载体,可以是数字、字符、图像等。
- 数据类型:数据的分类和定义,决定了数据的存储方式和操作。常见的数据类型包括整数、浮点数、字符、布尔值等。
数据结构
数据结构是数据元素之间的关系和操作的集合。它不仅仅是数据的存储方式,还包括对这些数据的操作(如插入、删除、查找等)。数据结构的选择直接影响算法的效率。
抽象数据类型(ADT)
抽象数据类型是对数据结构的抽象描述,定义了数据的逻辑结构和操作,而不关心其具体实现。常见的ADT包括栈、队列、列表、树等。
常见数据结构
线性结构
数组
定义:数组是一种连续内存空间存储相同类型元素的数据结构。
特点:支持随机访问,时间复杂度为O(1)**;但插入和删除操作的时间复杂度为O(n)**。
应用:适用于需要频繁访问元素的场景,如排序和查找。
正是因为数组在内存空间的地址是连续的,所以我们在删除或者增添元素的时候,就难免要移动其他元素的地址。数组的元素是不能删的,只能覆盖。
例如删除下标为3的元素,需要对下标为3的元素后面的所有元素都要做移动操作,如图所示:
二分查找
前提是数组为有序数组,同时还强调数组中无重复元素,因为一旦有重复元素,使用二分查找法返回的元素下标可能不是唯一的,这些都是使用二分法的前提条件。
写二分法经常写乱,主要是因为对区间的定义没有想清楚,区间的定义就是不变量。要在二分查找的过程中,保持不变量,就是在while寻找中每一次边界的处理都要坚持根据区间的定义来操作,这就是循环不变量规则。
写二分法,区间的定义一般为两种,左闭右闭即[left, right],或者左闭右开即[left, right)。
第一种写法:
定义 target 是在一个在左闭右闭的区间里,也就是[left, right] ,因为定义target在[left, right]区间,所以有如下两点:
while (left <= right) 要使用 <= ,因为left == right是有意义的,所以使用 <=
if (nums[middle] > target) right 要赋值为 middle - 1,因为当前这个nums[middle]一定不是target,那么接下来要查找的左区间结束下标位置就是 middle - 1
例如在数组:1,2,3,4,7,9,10中查找元素2,如图所示:

1 | class Solution: |
第二种写法:
定义 target 是在一个在左闭右开的区间里,也就是[left, right) ,那么二分法的边界处理方式则截然不同。有如下两点:
while (left < right),这里使用 < ,因为left == right在区间[left, right)是没有意义的
if (nums[middle] > target) right 更新为 middle,因为当前nums[middle]不等于target,去左区间继续寻找,而寻找区间是左闭右开区间,所以right更新为middle,即:下一个查询区间不会去比较nums[middle]
在数组:1,2,3,4,7,9,10中查找元素2,如图所示:
1
2
3
4
5
6
7
8
9
10
11
12
13
14class Solution:
def search(self, nums: List[int], target: int) -> int:
left, right = 0, len(nums) # 定义target在左闭右开的区间里,即:[left, right)
while left < right: # 因为left == right的时候,在[left, right)是无效的空间,所以使用 <
middle = left + (right - left) // 2
if nums[middle] > target:
right = middle # target 在左区间,在[left, middle)中
elif nums[middle] < target:
left = middle + 1 # target 在右区间,在[middle + 1, right)中
else:
return middle # 数组中找到目标值,直接返回下标
return -1 # 未找到目标值时间复杂度:O(log n)
空间复杂度:O(1)
移除元素
给你一个数组 nums
和一个值 val
,你需要 原地 移除所有数值等于 val 的元素,并返回移除后数组的新长度。
不要使用额外的数组空间,你必须仅使用 O(1) 额外空间并原地修改输入数组。元素的顺序可以改变。你不需要考虑数组中超出新长度后面的元素。数组的元素在内存地址中是连续的,不能单独删除数组中的某个元素,只能覆盖。
暴力解法:
这个题目暴力的解法就是两层for循环,一个for循环遍历数组元素 ,第二个for循环更新数组。时间复杂度是O(n^2),空间复杂度:O(1)

1 | class Solution: |
双指针法:
双指针法(快慢指针法): 通过一个快指针和慢指针在一个for循环下完成两个for循环的工作。
定义快慢指针
快指针:寻找新数组的元素 ,新数组就是不含有目标元素的数组
慢指针:指向更新 新数组下标的位置

双指针法(快慢指针法)在数组和链表的操作中是非常常见的,很多考察数组、链表、字符串等操作的面试题,都使用双指针法。这些实现方法并没有改变元素的相对位置!
1 | class Solution: |
- 时间复杂度:O(n)
- 空间复杂度:O(1)
有序数组的平方
给你一个按 非递减顺序 排序的整数数组 nums,返回 每个数字的平方 组成的新数组,要求也按 非递减顺序 排序。
示例 1:
- 输入:
nums = [-4,-1,0,3,10]
- 输出:
[0,1,9,16,100]
- 解释:平方后,数组变为
[16,1,0,9,100]
,排序后,数组变为[0,1,9,16,100]
暴力排序:
每个数平方之后,排个序,代码如下:
1 | class Solution: |
时间复杂度是 O(n + nlogn)
双指针法:
数组其实是有序的, 只不过负数平方之后可能成为最大数了。那么数组平方的最大值就在数组的两端,不是最左边就是最右边,不可能是中间。此时可以考虑双指针法了,i指向起始位置,j指向终止位置。
定义一个新数组result,和A数组一样的大小,让k指向result数组终止位置。
如果A[i] * A[i] < A[j] * A[j]
那么result[k--] = A[j] * A[j];
。如果A[i] * A[i] >= A[j] * A[j]
那么result[k--] = A[i] * A[i];
。
1 | class Solution: |
此时的时间复杂度为O(n)
长度最小的子数组
给定一个含有 n 个正整数的数组和一个正整数 s ,找出该数组中满足其和 ≥ s 的长度最小的 连续 子数组,并返回其长度。如果不存在符合条件的子数组,返回 0。
示例:
- 输入:
s = 7
,nums = [2,3,1,2,4,3]
- 输出:
2
- 解释:子数组 [4,3] 是该条件下的长度最小的子数组。
暴力解法:
两个for循环,然后不断的寻找符合条件的子序列
1 | class Solution: |
时间复杂度:O(n^2)
空间复杂度:O(1)
滑动窗口:
所谓滑动窗口,就是不断的调节子序列的起始位置和终止位置,从而得出我们要想的结果。
在暴力解法中,是一个for循环滑动窗口的起始位置,一个for循环为滑动窗口的终止位置,用两个for循环 完成了一个不断搜索区间的过程。
只用一个for循环,那么这个循环的索引,一定是表示 滑动窗口的终止位置。
那么问题来了, 滑动窗口的起始位置如何移动呢?这里还是以题目中的示例来举例,s=7, 数组是 2,3,1,2,4,3,来看一下查找的过程:

最后找到 4,3 是最短距离。
其实从动画中可以发现滑动窗口也可以理解为双指针法的一种!只不过这种解法更像是一个窗口的移动,所以叫做滑动窗口更适合一些。
题中实现滑动窗口,主要确定如下三点:
- 窗口内是什么?
- 如何移动窗口的起始位置?
- 如何移动窗口的结束位置?
窗口就是 满足其和 ≥ s 的长度最小的 连续 子数组。
窗口的起始位置如何移动:如果当前窗口的值大于等于s了,窗口就要向前移动了(也就是该缩小了)。
窗口的结束位置如何移动:窗口的结束位置就是遍历数组的指针,也就是for循环里的索引。
滑动窗口的精妙之处在于根据当前子序列和大小的情况,不断调节子序列的起始位置。从而将O(n^2)暴力解法降为O(n)。
1 | class Solution: |
时间复杂度:O(n)
空间复杂度:O(1)
螺旋矩阵II
给定一个正整数 n,生成一个包含 1 到 n^2 所有元素,且元素按顺时针顺序螺旋排列的正方形矩阵。
示例:
输入: 3
输出: [ [ 1, 2, 3 ], [ 8, 9, 4 ], [ 7, 6, 5 ] ]
求解本题依然是要坚持循环不变量原则。模拟顺时针画矩阵的过程:
- 填充上行从左到右
- 填充右列从上到下
- 填充下行从右到左
- 填充左列从下到上
由外向内一圈一圈这么画下去。可以发现这里的边界条件非常多,在一个循环中,如此多的边界条件,如果不按照固定规则来遍历,那就是一进循环深似海
这里一圈下来,我们要画每四条边,这四条边怎么画,每画一条边都要坚持一致的左闭右开,或者左开右闭的原则,这样这一圈才能按照统一的规则画下来。

这里每一种颜色,代表一条边,我们遍历的长度,可以看出每一个拐角处的处理规则,拐角处让给新的一条边来继续画。这也是坚持了每条边左闭右开的原则。
1 | class Solution: |
第一行输入为整数数组 Array 的长度 n,接下来 n 行,每行一个整数,表示数组的元素。随后的输入为需要计算总和的区间,直至文件结束。
输出每个指定区间内元素的总和。
1 | 输入: |
如果我查询m次,每次查询的范围都是从0 到 n - 1,那么该算法的时间复杂度是 O(n * m) m 是查询的次数,如果查询次数非常大的话,这个时间复杂度也是非常大的。
接下来我们来引入前缀和,看看前缀和如何解决这个问题。前缀和的思想是重复利用计算过的子数组之和,从而降低区间查询需要累加计算的次数。
前缀和 在涉及计算区间和的问题时非常有用!

如果,我们想统计,在vec数组上 下标 2 到下标 5 之间的累加和,用 p[5] - p[1] 就可以了。
p 数组是我们之前就计算好的累加和,所以后面每次求区间和的之后 我们只需要 O(1) 的操作。
特别注意: 在使用前缀和求解的时候,要特别注意 求解区间。如上图,如果我们要求 区间下标 [2, 5] 的区间和,那么应该是 p[5] - p[1],而不是 p[5] - p[2]。
1 | import sys |
开发商购买土地
在一个城市区域内,被划分成了n * m个连续的区块,每个区块都拥有不同的权值,代表着其土地价值。目前,有两家开发公司,A 公司和 B 公司,希望购买这个城市区域的土地。
现在,需要将这个城市区域的所有区块分配给 A 公司和 B 公司。
然而,由于城市规划的限制,只允许将区域按横向或纵向划分成两个子区域,而且每个子区域都必须包含一个或多个区块。
为了确保公平竞争,你需要找到一种分配方式,使得 A 公司和 B 公司各自的子区域内的土地总价值之差最小。
【输入描述】
第一行输入两个正整数,代表 n 和 m。
接下来的 n 行,每行输出 m 个正整数。
输出描述
请输出一个整数,代表两个子区域内土地总价值之间的最小差距。
1 | 输入: |
暴力求解
n^3 的时间复杂度,一个 for 枚举分割线, 嵌套 两个for 去累加区间里的和。
1 | def main(): |
前缀和
先将 行方向,和 列方向的和求出来,这样可以方便知道 划分的两个区间的和。
1 | def main(): |
时间复杂度: O(n^2)
时间复杂度也是 O(n^2)
链表
定义:链表是一种通过指针将元素链接在一起的数据结构。每一个节点由两部分组成,一个是数据域一个是指针域(存放指向下一个节点的指针),最后一个节点的指针域指向null(空指针的意思)。
链表的入口节点称为链表的头结点也就是head。
链表在内存中可不是连续分布的。链表是通过指针域的指针链接在内存中各个节点。所以链表中的节点在内存中不是连续分布的 ,而是散乱分布在内存中的某地址上,分配机制取决于操作系统的内存管理。
这个链表起始节点为2, 终止节点为7, 各个节点分布在内存的不同地址空间上,通过指针串联在一起。
类型:
单向链表:每个节点只指向下一个节点。
双向链表:每个节点有指向前一个和后一个节点的指针。双链表 既可以向前查询也可以向后查询。
循环链表:尾节点指向头节点,形成一个环。循环链表可以用来解决约瑟夫环问题。
特点:插入和删除操作的时间复杂度为O(1)**,但访问元素的时间复杂度为O(n)**。
应用:适用于需要频繁插入和删除,较少查询的场景,如实现栈和队列。
定义链表节点:
1
2
3
4class ListNode:
def __init__(self, val, next=None):
self.val = val
self.next = next
移除链表元素
删除链表中等于给定值 val 的所有节点。
示例 1: 输入:head = [1,4,2,4]
, val = 4
输出:[1,2]

如果使用C,C++编程语言的话,不要忘了还要从内存中删除这两个移除的节点, 如果使用java ,python的话就不用手动管理内存了。
因为单链表的特殊性,只能指向下一个节点,刚刚删除的是链表的中第二个,和第四个节点,那么如果删除的是头结点,就涉及如下链表操作的两种方式:
直接使用原来的链表来进行删除操作。
设置一个虚拟头结点再进行删除操作。

移除头结点和移除其他节点的操作是不一样的,因为链表的其他节点都是通过前一个节点来移除当前节点,而头结点没有前一个节点,要将头结点向后移动一位。
在单链表中移除头结点 和 移除其他节点的操作方式是不一样,其实在写代码的时候也会发现,需要单独写一段逻辑来处理移除头结点的情况。

可以设置一个虚拟头结点,这样原链表的所有节点就都可以按照统一的方式进行移除了。

这里来给链表添加一个虚拟头结点为新的头结点,此时要移除这个旧头结点元素1,就可以使用和移除链表其他节点的方式统一。
最后在题目中,return 头结点的时候,别忘了 return dummyNode.next;
, 这才是新的头结点。
直接使用原来的链表来进行移除节点操作:
1 | Definition for singly-linked list. |
- 时间复杂度: O(n)
- 空间复杂度: O(1)
设置一个虚拟头结点在进行移除节点操作:
1 | Definition for singly-linked list. |
- 时间复杂度: O(n)
- 空间复杂度: O(1)
设计链表
在链表类中实现这些功能:
get(index)
:获取链表中第 index 个节点的值。如果索引无效,则返回-1。addAtHead(val)
:在链表的第一个元素之前添加一个值为 val 的节点。插入后,新节点将成为链表的第一个节点。addAtTail(val)
:将值为 val 的节点追加到链表的最后一个元素。addAtIndex(index,val)
:在链表中的第 index 个节点之前添加值为 val 的节点。如果 index 等于链表的长度,则该节点将附加到链表的末尾。如果 index 大于链表长度,则不会插入节点。如果index小于0,则在头部插入节点。deleteAtIndex(index)
:如果索引 index 有效,则删除链表中的第 index 个节点。
删除链表节点:

添加链表节点:

这道题目设计链表的五个接口:
- 获取链表第index个节点的数值
- 在链表的最前面插入一个节点
- 在链表的最后面插入一个节点
- 在链表第index个节点前面插入一个节点
- 删除链表的第index个节点
这五个接口,已经覆盖了链表的常见操作
单链表法:
1 | class ListNode: |
双链表法:
1 | class ListNode: |
- 时间复杂度: 涉及
index
的相关操作为 O(index), 其余为 O(1) - 空间复杂度: O(n)
翻转链表
题意:反转一个单链表。
示例:
输入: 1->2->3->4->5->NULL
输出: 5->4->3->2->1->NULL
如果再定义一个新的链表,实现链表元素的反转,其实这是对内存空间的浪费。
其实只需要改变链表的next指针的指向,直接将链表反转 ,而不用重新定义一个新的链表,如图所示:

没有添加或者删除节点,仅仅是改变next指针的方向,如动画所示:

首先定义一个cur指针,指向头结点,再定义一个pre指针,初始化为null。
然后就要开始反转了,首先要把 cur->next 节点用tmp指针保存一下,也就是保存一下这个节点。
为什么要保存一下这个节点呢,因为接下来要改变 cur->next 的指向了,将cur->next 指向pre ,此时已经反转了第一个节点了。
接下来,就是循环走如下代码逻辑了,继续移动pre和cur指针。
最后,cur 指针已经指向了null,循环结束,链表也反转完毕了。 此时我们return pre指针就可以了,pre指针就指向了新的头结点。
双指针法:
1 | Definition for singly-linked list. |
- 时间复杂度: O(n)
- 空间复杂度: O(1)
递归法:
递归法相对抽象一些,但是其实和双指针法是一样的逻辑,同样是当cur为空的时候循环结束,不断将cur指向pre的过程。
关键是初始化的地方,可能有的同学会不理解, 可以看到双指针法中初始化 cur = head,pre = NULL,在递归法中可以从如下代码看出初始化的逻辑也是一样的,只不过写法变了。
1 | Definition for singly-linked list. |
- 时间复杂度: O(n), 要递归处理链表的每个节点
- 空间复杂度: O(n), 递归调用了 n 层栈空间
两两交换链表中的节点
给定一个链表,两两交换其中相邻的节点,并返回交换后的链表。你不能只是单纯的改变节点内部的值,而是需要实际的进行节点交换。
输入:head = [1,2,3,4]
输出:[2,1,4,3]

建议使用虚拟头结点,这样会方便很多,要不然每次针对头结点(没有前一个指针指向头结点),还要单独处理。接下来就是交换相邻两个元素了,此时一定要画图,不画图,操作多个指针很容易乱,而且要操作的先后顺序,初始时,cur指向虚拟头结点,然后进行如下三步:

1 | Definition for singly-linked list. |
时间复杂度:O(n)
空间复杂度:O(1)
递归法:
1 | 递归版本 |
删除链表的倒数第N个节点
给你一个链表,删除链表的倒数第 n 个结点,并且返回链表的头结点。
输入:head = [1,2,3,4,5]
, n = 2
输出:[1,2,3,5]
双指针的经典应用,如果要删除倒数第n个节点,让fast移动n步,然后让fast和slow同时移动,直到fast指向链表末尾。删掉slow所指向的节点就可以了。但要注意一些细节:
首先这里我推荐大家使用虚拟头结点,这样方便处理删除实际头结点的逻辑
定义fast指针和slow指针,初始值为虚拟头结点,如图:
fast首先走n + 1步 ,为什么是n+1呢,因为只有这样同时移动的时候slow才能指向删除节点的上一个节点(方便做删除操作),如图:

- fast和slow同时移动,直到fast指向末尾,如题:

- 删除slow指向的下一个节点,如图:

1 | Definition for singly-linked list. |
- 时间复杂度: O(n)
- 空间复杂度: O(1)
链表相交
给你两个单链表的头节点 headA 和 headB ,请你找出并返回两个单链表相交的起始节点。如果两个链表没有交点,返回 null 。图示两个链表在节点 c1 开始相交:

题目数据 保证 整个链式结构中不存在环。注意,函数返回结果后,链表必须 保持其原始结构 。
简单来说,就是求两个链表交点节点的指针。 这里同学们要注意,交点不是数值相等,而是指针相等。为了方便举例,假设节点元素数值相等,则节点指针相等。
看如下两个链表,目前curA指向链表A的头结点,curB指向链表B的头结点:

我们求出两个链表的长度,并求出两个链表长度的差值,然后让curA移动到,和curB 末尾对齐的位置,如图:

此时我们就可以比较curA和curB是否相同,如果不相同,同时向后移动curA和curB,如果遇到curA == curB,则找到交点。否则循环退出返回空指针。
1 | class Solution: |
- 时间复杂度:O(n + m)
- 空间复杂度:O(1)
环形链表II
给定一个链表,返回链表开始入环的第一个节点。 如果链表无环,则返回 null。
为了表示给定链表中的环,使用整数 pos 来表示链表尾连接到链表中的位置(索引从 0 开始)。 如果 pos 是 -1,则在该链表中没有环。
说明:不允许修改给定的链表。

这道题目,不仅考察对链表的操作,而且还需要一些数学运算。
主要考察两个知识点:
判断链表是否环
可以使用快慢指针法,分别定义 fast 和 slow 指针,从头结点出发,fast指针每次移动两个节点,slow指针每次移动一个节点,如果 fast 和 slow指针在途中相遇 ,说明这个链表有环。
fast指针一定先进入环中,如果fast指针和slow指针相遇的话,一定是在环中相遇。
这是因为fast是走两步,slow是走一步,其实相对于slow来说,fast是一个节点一个节点的靠近slow的,所以fast一定可以和slow重合。
如果有环,如何找到这个环的入口
假设从头结点到环形入口节点 的节点数为
x
。 环形入口节点到 fast指针与slow指针相遇节点 节点数为y
。 从相遇节点 再到环形入口节点节点数为z
。 如图所示:那么相遇时: slow指针走过的节点数为:
x + y
, fast指针走过的节点数:x + y + n (y + z)
,n
为fast指针在环内走了n圈才遇到slow指针,(y+z)
为 一圈内节点的个数A。因为fast指针是一步走两个节点,slow指针一步走一个节点, 所以 fast指针走过的节点数 = slow指针走过的节点数 * 2:
(x + y) * 2 = x + y + n (y + z)
两边消掉一个
(x+y)
:x + y = n (y + z)
因为要找环形的入口,那么要求的是
x
,将x单独放在左面:x = n (y + z) - y
,再从
n(y+z)
中提出一个(y+z)
来,整理公式之后为如下公式:x = (n - 1) (y + z) + z
注意这里n一定是大于等于1的,因为 fast指针至少要多走一圈才能相遇slow指针。
这个公式说明什么呢?
先拿n为1的情况来举例,意味着fast指针在环形里转了一圈之后,就遇到了 slow指针了。当 n为1的时候,公式就化解为
x = z
,这就意味着,从头结点出发一个指针,从相遇节点 也出发一个指针,这两个指针每次只走一个节点, 那么当这两个指针相遇的时候就是 环形入口的节点。
也就是在相遇节点处,定义一个指针index1,在头结点处定一个指针index2。让index1和index2同时移动,每次移动一个节点, 那么他们相遇的地方就是 环形入口的节点。动画如下:
n 如果大于1是什么情况呢,就是fast指针在环形转n圈之后才遇到 slow指针。
其实这种情况和n为1的时候 效果是一样的,一样可以通过这个方法找到 环形的入口节点,只不过,index1 指针在环里 多转了(n-1)圈,然后再遇到index2,相遇点依然是环形的入口节点。
1 | Definition for singly-linked list. |
- 时间复杂度: O(n),快慢指针相遇前,指针走的次数小于链表长度,快慢指针相遇后,两个index指针走的次数也小于链表长度,总体为走的次数小于 2n
- 空间复杂度: O(1)
栈
定义:栈是一种后进先出(LIFO)的数据结构。栈是以底层容器完成其所有的工作,对外提供统一的接口,底层容器是可插拔的(也就是说我们可以控制使用哪种容器来实现栈的功能)。
操作:
- Push:将元素压入栈顶。
- Pop:从栈顶弹出元素。
- Peek:查看栈顶元素而不弹出。
特点:**所有操作的时间复杂度为O(1)**。
应用:适用于需要后进先出的场景,如函数调用栈、表达式求值等。
用 栈 实现 队列
1 | class MyQueue: |
有效的括号
括号匹配是使用栈解决的经典问题。题意其实就像我们在写代码的过程中,要求括号的顺序是一样的,有左括号,相应的位置必须要有右括号。
如果还记得编译原理的话,编译器在 词法分析的过程中处理括号、花括号等这个符号的逻辑,也是使用了栈这种数据结构。
第一种情况:已经遍历完了字符串,但是栈不为空,说明有相应的左括号没有右括号来匹配,所以return false
第二种情况:遍历字符串匹配的过程中,发现栈里没有要匹配的字符。所以return false
第三种情况:遍历字符串匹配的过程中,栈已经为空了,没有匹配的字符了,说明右括号没有找到对应的左括号return false
那么什么时候说明左括号和右括号全都匹配了呢,就是字符串遍历完之后,栈是空的,就说明全都匹配了。
1 | # 方法一,仅使用栈,更省空间 |
- 时间复杂度: O(n)
- 空间复杂度: O(n)
1 | # 方法二:使用字典 |
删除字符串中的所有相邻重复项
给出由小写字母组成的字符串 S
,重复项删除操作会选择两个相邻且相同的字母,并删除它们。
在 S
上反复执行重复项删除操作,直到无法继续删除。在完成所有重复项删除操作后返回最终的字符串。示例:
- 输入:
"abbaca"
- 输出:
"ca"
- 解释:在
"abbaca"
中,我们可以删除"bb"
由于两字母相邻且相同,这是此时唯一可以执行删除操作的重复项。之后我们得到字符串"aaca"
,其中又只有"aa"
可以执行重复项删除操作,所以最后的字符串为"ca"
。
我们在删除相邻重复项的时候,其实就是要知道当前遍历的这个元素,我们在前一位是不是遍历过一样数值的元素,那么如何记录前面遍历过的元素呢?
所以就是用栈来存放,那么栈的目的,就是存放遍历过的元素,当遍历当前的这个元素的时候,去栈里看一下我们是不是遍历过相同数值的相邻元素。然后再去做对应的消除操作。
从栈中弹出剩余元素,此时是字符串ac,因为从栈里弹出的元素是倒序的,所以再对字符串进行反转一下,就得到了最终的结果。
1 | # 方法一,使用栈 |
- 时间复杂度: O(n)
- 空间复杂度: O(n)
当然可以拿字符串直接作为栈,这样省去了栈还要转为字符串的操作。
1 | # 方法二,使用双指针模拟栈,如果不让用栈可以作为备选方法。 |
逆波兰表达式求值
根据 逆波兰表示法,求表达式的值。有效的运算符包括 + , - , * , / 。每个运算对象可以是整数,也可以是另一个逆波兰表达式。
整数除法只保留整数部分。 给定逆波兰表达式总是有效的。换句话说,表达式总会得出有效数值且不存在除数为 0 的情况。示例:
- 输入:
["2", "1", "+", "3", " * "]
- 输出:
9
- 解释: 该算式转化为常见的中缀算术表达式为:
((2 + 1) * 3) = 9
逆波兰表达式:是一种后缀表达式,所谓后缀就是指运算符写在后面。平常使用的算式则是一种中缀表达式,如 ( 1 + 2 ) * ( 3 + 4 )
。该算式的逆波兰表达式写法为 ( ( 1 2 + ) ( 3 4 + ) * )
。
逆波兰表达式主要有以下两个优点:
- 去掉括号后表达式无歧义,上式即便写成
1 2 + 3 4 + *
也可以依据次序计算出正确结果。 - 适合用栈操作运算:遇到数字则入栈;遇到运算符则取出栈顶两个数字进行计算,并将结果压入栈中。
1 | from operator import add, sub, mul |
- 时间复杂度: O(n)
- 空间复杂度: O(n)
1 | # 使用eval()相对较慢的方法: |
每日温度
请根据每日 气温 列表,重新生成一个列表。对应位置的输出为:要想观测到更高的气温,至少需要等待的天数。如果气温在这之后都不会升高,请在该位置用 0
来代替。
例如,给定一个列表 temperatures = [73, 74, 75, 71, 69, 72, 76, 73]
,你的输出应该是 [1, 1, 4, 2, 1, 1, 0, 0]
。
首先想到的当然是暴力解法,两层for
循环,把至少需要等待的天数就搜出来了。时间复杂度是O(n^2)那么接下来在来看看使用单调栈的解法。
通常是一维数组,要寻找任一个元素的右边或者左边第一个比自己大或者小的元素的位置,此时我们就要想到可以用单调栈了。时间复杂度为O(n)。例如本题其实就是找找到一个元素右边第一个比自己大的元素。
单调栈的本质是空间换时间,因为在遍历的过程中需要用一个栈来记录右边第一个比当前元素高的元素,优点是整个数组只需要遍历一次。
更直白来说,就是用一个栈来记录我们遍历过的元素,因为我们遍历数组的时候,我们不知道之前都遍历了哪些元素,以至于遍历一个元素找不到是不是之前遍历过一个更小的,所以我们需要用一个容器(这里用单调栈)来记录我们遍历过的元素。
在使用单调栈的时候首先要明确如下几点:
单调栈里存放的元素是什么?单调栈里只需要存放元素的下标
i
就可以了,如果需要使用对应的元素,直接T[i]
就可以获取。单调栈里元素是递增呢? 还是递减呢?注意以下讲解中,顺序的描述为 从栈头到栈底的顺序,
这里我们要使用递增循序(再强调一下是指从栈头到栈底的顺序),因为只有递增的时候,栈里要加入一个元素i的时候,才知道 栈顶元素 在数组中右面第一个比栈顶元素大的元素是
i
。即:如果求一个元素右边第一个更大元素,单调栈就是递增的,如果求一个元素右边第一个更小元素,单调栈就是递减的。
使用单调栈主要有三个判断条件。
- 当前遍历的元素
T[i]
小于栈顶元素T[st.top()]
的情况 - 当前遍历的元素
T[i]
等于栈顶元素T[st.top()]
的情况 - 当前遍历的元素
T[i]
大于栈顶元素T[st.top()]
的情况
接下来我们用temperatures = [73, 74, 75, 71, 71, 72, 76, 73]
为例来逐步分析,输出应该是 [1, 1, 4, 2, 1, 1, 0, 0]
。
首先先将第一个遍历元素加入单调栈:

加入T[1] = 74
,因为T[1] > T[0]
(当前遍历的元素T[i]
大于栈顶元素T[st.top()]
的情况)。
我们要保持一个递增单调栈(从栈头到栈底),所以将T[0]
弹出,T[1]
加入,此时result
数组可以记录了,result[0] = 1
,即T[0]
右面第一个比T[0]
大的元素是T[1]
。

加入T[2]
,同理,T[1]
弹出

加入T[3]
,T[3] < T[2]
(当前遍历的元素T[i]
小于栈顶元素T[st.top()]
的情况),加T[3]
加入单调栈。

加入T[4]
,T[4] == T[3]
(当前遍历的元素T[i]
等于栈顶元素T[st.top()]
的情况),此时依然要加入栈,不用计算距离,因为我们要求的是右面第一个大于本元素的位置,而不是大于等于!

加入T[5]
,T[5] > T[4]
(当前遍历的元素T[i]
大于栈顶元素T[st.top()]
的情况),将T[4]
弹出,同时计算距离,更新result

T[4]
弹出之后, T[5] > T[3]
(当前遍历的元素T[i]
大于栈顶元素T[st.top()]
的情况),将T[3]
继续弹出,同时计算距离,更新result

直到发现T[5]
小于T[st.top()]
,终止弹出,将T[5]
加入单调栈

加入T[6]
,同理,需要将栈里的T[5]
,T[2]
弹出

同理,继续弹出

此时栈里只剩下了T[6]

加入T[7]
, T[7] < T[6]
直接入栈,这就是最后的情况,result
数组也更新完了。

此时有同学可能就疑惑了,那result[6]
, result[7]
怎么没更新啊,元素也一直在栈里。
其实定义result
数组的时候,就应该直接初始化为0
,如果result
没有更新,说明这个元素右面没有更大的了,也就是为0
。
通过以上过程,大家可以自己再模拟一遍,就会发现:只有单调栈递增(从栈口到栈底顺序),就是求右边第一个比自己大的,单调栈递减的话,就是求右边第一个比自己小的。
1 | class Solution: |
- 时间复杂度:O(n)
- 空间复杂度:O(n)
下一个更大元素 I
给你两个 没有重复元素 的数组 nums1
和 nums2
,其中nums1
是 nums2
的子集。
请你找出 nums1
中每个元素在 nums2
中的下一个比其大的值。nums1
中数字 x
的下一个更大元素是指 x
在 nums2
中对应位置的右边的第一个比 x
大的元素。如果不存在,对应位置输出 -1
。示例 :
输入: nums1 = [4,1,2], nums2 = [1,3,4,2]
输出: [-1,3,-1]
解释:
对于 num1
中的数字 4
,你无法在第二个数组中找到下一个更大的数字,因此输出 -1
。
对于 num1
中的数字 1
,第二个数组中数字1右边的下一个较大数字是 3
。
对于 num1
中的数字 2
,第二个数组中没有下一个更大的数字,因此输出 -1
。
本题则是说nums1
是 nums2
的子集,找nums1
中的元素在nums2
中下一个比当前元素大的元素。需要对单调栈使用的更熟练一些,才能顺利的把本题写出来。从题目示例中我们可以看出最后是要求nums1
的每个元素在nums2
中下一个比当前元素大的元素,那么就要定义一个和nums1
一样大小的数组result
来存放结果。
这么定义这个result
数组初始化应该为多少呢?
题目说如果不存在对应位置就输出 -1
,所以result数组如果某位置没有被赋值,那么就应该是是-1
,所以就初始化为-1
。在遍历nums2
的过程中,我们要判断nums2[i]
是否在nums1
中出现过,因为最后是要根据nums1
元素的下标来更新result
数组。
**注意题目中说是两个没有重复元素 的数组 nums1
和 nums2
**。
没有重复元素,我们就可以用map
来做映射了。根据数值快速找到下标,还可以判断nums2[i]
是否在nums1
中出现过。
栈头到栈底的顺序,要从小到大,也就是保持栈里的元素为递增顺序。只要保持递增,才能找到右边第一个比自己大的元素。
接下来就要分析如下三种情况,一定要分析清楚。
情况一:当前遍历的元素
T[i]
小于栈顶元素T[st.top()]
的情况:此时满足递增栈(栈头到栈底的顺序),所以直接入栈。情况二:当前遍历的元素
T[i]
等于栈顶元素T[st.top()]
的情况:如果相等的话,依然直接入栈,因为我们要求的是右边第一个比自己大的元素,而不是大于等于!情况三:当前遍历的元素
T[i]
大于栈顶元素T[st.top()]
的情况:此时如果入栈就不满足递增栈了,这也是找到右边第一个比自己大的元素的时候。
判断栈顶元素是否在nums1
里出现过,(注意栈里的元素是nums2
的元素),如果出现过,开始记录结果。
1 | class Solution: |
下一个更大元素 II
给定一个循环数组(最后一个元素的下一个元素是数组的第一个元素),输出每个元素的下一个更大元素。数字 x
的下一个更大的元素是按数组遍历顺序,这个数字之后的第一个比它更大的数,这意味着你应该循环地搜索它的下一个更大的数。如果不存在,则输出 -1
。示例 :
- 输入:
[1,2,1]
- 输出:
[2,-1,2]
- 解释: 第一个
1
的下一个更大的数是2
;数字2
找不到下一个更大的数;第二个1
的下一个最大的数需要循环搜索,结果也是2
。
这道题和每日温度如出一辙。不过,本题要循环数组了。将两个nums数组拼接在一起,使用单调栈计算出每一个元素的下一个最大值,最后再把结果集即result数组resize到原数组大小就可以了。
1 | class Solution: |
1 | class Solution: |
接雨水
给定 n
个非负整数表示每个宽度为 1
的柱子的高度图,计算按此排列的柱子,下雨之后能接多少雨水。示例 :
- 输入:
height = [0,1,0,2,1,0,1,3,2,1,2,1]
- 输出:
6
- 解释:上面是由数组
[0,1,0,2,1,0,1,3,2,1,2,1]
表示的高度图,在这种情况下,可以接6
个单位的雨水(蓝色部分表示雨水)。
本文深度讲解如下三种方法:
暴力解法
本题暴力解法也是也是使用双指针。首先要明确,要按照行来计算,还是按照列来计算。
按照行来计算如图:
按照列来计算如图:
如果按照列来计算的话,宽度一定是1了,我们再把每一列的雨水的高度求出来就可以了。
可以看出每一列雨水的高度,取决于,该列 左侧最高的柱子和右侧最高的柱子中最矮的那个柱子的高度。
列4 左侧最高的柱子是列3,高度为2(以下用lHeight表示)。
列4 右侧最高的柱子是列7,高度为3(以下用rHeight表示)。
列4 柱子的高度为1(以下用height表示)
那么列4的雨水高度为 列3和列7的高度最小值减列4高度,即: min(lHeight, rHeight) - height。列4的雨水高度求出来了,宽度为1,相乘就是列4的雨水体积了。一样的方法,只要从头遍历一遍所有的列,然后求出每一列雨水的体积,相加之后就是总雨水的体积了。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17class Solution:
def trap(self, height: List[int]) -> int:
res = 0
for i in range(len(height)):
if i == 0 or i == len(height)-1: continue
lHight = height[i-1]
rHight = height[i+1]
for j in range(i-1):
if height[j] > lHight:
lHight = height[j]
for k in range(i+2,len(height)):
if height[k] > rHight:
rHight = height[k]
res1 = min(lHight,rHight) - height[i]
if res1 > 0:
res += res1
return res因为每次遍历列的时候,还要向两边寻找最高的列,所以时间复杂度为O(n^2),空间复杂度为O(1)。
双指针优化
为了得到两边的最高高度,使用了双指针来遍历,每到一个柱子都向两边遍历一遍,这其实是有重复计算的。我们把每一个位置的左边最高高度记录在一个数组上(maxLeft),右边最高高度记录在一个数组上(maxRight),这样就避免了重复计算。
当前位置,左边的最高高度是前一个位置的左边最高高度和本高度的最大值。
即从左向右遍历:
maxLeft[i] = max(height[i], maxLeft[i - 1])
从右向左遍历:
maxRight[i] = max(height[i], maxRight[i + 1])
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16class Solution:
def trap(self, height: List[int]) -> int:
leftheight, rightheight = [0]*len(height), [0]*len(height)
leftheight[0]=height[0]
for i in range(1,len(height)):
leftheight[i]=max(leftheight[i-1],height[i])
rightheight[-1]=height[-1]
for i in range(len(height)-2,-1,-1):
rightheight[i]=max(rightheight[i+1],height[i])
result = 0
for i in range(0,len(height)):
summ = min(leftheight[i],rightheight[i])-height[i]
result += summ
return result单调栈
单调栈就是保持栈内元素有序。需要我们自己维持顺序,没有现成的容器可以用。通常是一维数组,要寻找任一个元素的右边或者左边第一个比自己大或者小的元素的位置,此时我们就要想到可以用单调栈了。
而接雨水这道题目,我们正需要寻找一个元素,右边最大元素以及左边最大元素,来计算雨水面积。
首先单调栈是按照行方向来计算雨水,如图:
使用单调栈内元素的顺序
从栈头(元素从栈头弹出)到栈底的顺序应该是从小到大的顺序。
因为一旦发现添加的柱子高度大于栈头元素了,此时就出现凹槽了,栈头元素就是凹槽底部的柱子,栈头第二个元素就是凹槽左边的柱子,而添加的元素就是凹槽右边的柱子。
关于单调栈的顺序给大家一个总结:求一个元素右边第一个更大元素,单调栈就是递增的,求一个元素右边第一个更小元素,单调栈就是递减的。
遇到相同高度的柱子怎么办
遇到相同的元素,更新栈内下标,就是将栈里元素(旧下标)弹出,将新元素(新下标)加入栈中。
例如 5 5 1 3 这种情况。如果添加第二个5的时候就应该将第一个5的下标弹出,把第二个5添加到栈中。
因为我们要求宽度的时候 如果遇到相同高度的柱子,需要使用最右边的柱子来计算宽度。
栈里要保存什么数值
使用单调栈,也是通过 长 * 宽 来计算雨水面积的。长就是通过柱子的高度来计算,宽是通过柱子之间的下标来计算,
那么栈里有没有必要保存柱子的高度和下标呢?其实不用,栈里就存放下标就行,想要知道对应的高度,通过
height[stack.top()]
就知道弹出的下标对应的高度了。先将下标0的柱子加入到栈中,
st.push(0);
。 栈中存放我们遍历过的元素,所以先将下标0加进来。然后开始从下标1开始遍历所有的柱子,for (int i = 1; i < height.size(); i++)
。如果当前遍历的元素(柱子)高度小于栈顶元素的高度,就把这个元素加入栈中,因为栈里本来就要保持从小到大的顺序(从栈头到栈底)。
如果当前遍历的元素(柱子)高度等于栈顶元素的高度,要跟更新栈顶元素,因为遇到相相同高度的柱子,需要使用最右边的柱子来计算宽度。
如果当前遍历的元素(柱子)高度大于栈顶元素的高度,此时就出现凹槽了,如图所示:
取栈顶元素,将栈顶元素弹出,这个就是凹槽的底部,也就是中间位置,下标记为
mid
,对应的高度为height[mid]
(就是图中的高度1
)。此时的栈顶元素st.top()
,就是凹槽的左边位置,下标为st.top()
,对应的高度为height[st.top()]
(就是图中的高度2
)。当前遍历的元素
i
,就是凹槽右边的位置,下标为i
,对应的高度为height[i]
(就是图中的高度3
)。此时大家应该可以发现其实就是栈顶和栈顶的下一个元素以及要入栈的元素,三个元素来接水!那么雨水高度是
min(凹槽左边高度, 凹槽右边高度) - 凹槽底部高度
,代码为:int h = min(height[st.top()], height[i]) - height[mid];
雨水的宽度是
凹槽右边的下标 - 凹槽左边的下标 - 1
(因为只求中间宽度),代码为:int w = i - st.top() - 1 ;
当前凹槽雨水的体积就是:
h * w
。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61class Solution:
def trap(self, height: List[int]) -> int:
# 单调栈
'''
单调栈是按照 行 的方向来计算雨水
从栈顶到栈底的顺序:从小到大
通过三个元素来接水:栈顶,栈顶的下一个元素,以及即将入栈的元素
雨水高度是 min(凹槽左边高度, 凹槽右边高度) - 凹槽底部高度
雨水的宽度是 凹槽右边的下标 - 凹槽左边的下标 - 1(因为只求中间宽度)
'''
# stack储存index,用于计算对应的柱子高度
stack = [0]
result = 0
for i in range(1, len(height)):
# 情况一
if height[i] < height[stack[-1]]:
stack.append(i)
# 情况二
# 当当前柱子高度和栈顶一致时,左边的一个是不可能存放雨水的,所以保留右侧新柱子
# 需要使用最右边的柱子来计算宽度
elif height[i] == height[stack[-1]]:
stack.pop()
stack.append(i)
# 情况三
else:
# 抛出所有较低的柱子
while stack and height[i] > height[stack[-1]]:
# 栈顶就是中间的柱子:储水槽,就是凹槽的地步
mid_height = height[stack[-1]]
stack.pop()
if stack:
right_height = height[i]
left_height = height[stack[-1]]
# 两侧的较矮一方的高度 - 凹槽底部高度
h = min(right_height, left_height) - mid_height
# 凹槽右侧下标 - 凹槽左侧下标 - 1: 只求中间宽度
w = i - stack[-1] - 1
# 体积:高乘宽
result += h * w
stack.append(i)
return result
# 单调栈压缩版
class Solution:
def trap(self, height: List[int]) -> int:
stack = [0]
result = 0
for i in range(1, len(height)):
while stack and height[i] > height[stack[-1]]:
mid_height = stack.pop()
if stack:
# 雨水高度是 min(凹槽左侧高度, 凹槽右侧高度) - 凹槽底部高度
h = min(height[stack[-1]], height[i]) - height[mid_height]
# 雨水宽度是 凹槽右侧的下标 - 凹槽左侧的下标 - 1
w = i - stack[-1] - 1
# 累计总雨水体积
result += h * w
stack.append(i)
return result
柱状图中最大的矩形
给定 n
个非负整数,用来表示柱状图中各个柱子的高度。每个柱子彼此相邻,且宽度为 1
。求在该柱状图中,能够勾勒出来的矩形的最大面积。

输入:[2,1,5,6,2,3]
输出:10
本题和接雨水,是遥相呼应的两道题目,建议都要仔细做一做,原理上有很多相同的地方,但细节上又有差异,更可以加深对单调栈的理解!
暴力解法
1 | class Solution: |
双指针解法
1 | class Solution: |
双指针解法
那么因为本题是要找每个柱子左右两边第一个小于该柱子的柱子,所以从栈头(元素从栈头弹出)到栈底的顺序应该是从大到小的顺序!难就难在本题要记录记录每个柱子 左边第一个小于该柱子的下标,而不是左边第一个小于该柱子的高度。

只有栈里从大到小的顺序,才能保证栈顶元素找到左右两边第一个小于栈顶元素的柱子。所以本题单调栈的顺序正好与接雨水反过来。
此时大家应该可以发现其实就是栈顶和栈顶的下一个元素以及要入栈的三个元素组成了我们要求最大面积的高度和宽度
在 height
数组上后,都加了一个元素0
,首先来说末尾为什么要加元素0
?
如果数组本身就是升序的,例如[2,4,6,8]
,那么入栈之后 都是单调递减,一直都没有走 情况三 计算结果的哪一步,所以最后输出的就是0
了。 如图:

那么结尾加一个0
,就会让栈里的所有元素,走到情况三的逻辑。开头为什么要加元素0
?
如果数组本身是降序的,例如 [8,6,4,2]
,在 8
入栈后,6
开始与8
进行比较,此时我们得到 mid(8),right(6)
,但是得不到 left
。(mid
、left
,right
都是对应版本一里的逻辑)
因为 将 8
弹出之后,栈里没有元素了,那么为了避免空栈取值,直接跳过了计算结果的逻辑。之后又将6
加入栈(此时8
已经弹出了),然后 就是 4
与 栈口元素 6
进行比较,周而复始,那么计算的最后结果result
就是0
。 如图所示:

1 | class Solution: |
1 | # 单调栈精简 |
队列
定义:队列是一种先进先出(FIFO)的数据结构。
操作:
- Enqueue:将元素加入队尾。
- Dequeue:从队头移除元素。
- Peek:查看队头元素而不移除。
类型:
- 普通队列:基本的FIFO结构。
- 优先队列:元素按优先级出队,通常用堆实现。
- 双端队列(Deque):允许从两端进行插入和删除操作。
特点:所有操作的时间复杂度为O(1)。
应用:适用于需要先进先出的场景,如任务调度、缓冲区管理等。
用 队列 实现 栈
队列模拟栈,其实一个队列就够了,那么我们先说一说两个队列来实现栈的思路。
队列是先进先出的规则,把一个队列中的数据导入另一个队列中,数据的顺序并没有变,并没有变成先进后出的顺序。
所以用栈实现队列, 和用队列实现栈的思路还是不一样的,这取决于这两个数据结构的性质。
但是依然还是要用两个队列来模拟栈,只不过没有输入和输出的关系,而是另一个队列完全用来备份的!
如下面动画所示,用两个队列que1和que2实现队列的功能,que2其实完全就是一个备份的作用,把que1最后面的元素以外的元素都备份到que2,然后弹出最后面的元素,再把其他元素从que2导回que1。
1 | class MyStack: |
滑动窗口最大值
给定一个数组 nums
,有一个大小为 k
的滑动窗口从数组的最左侧移动到数组的最右侧。你只可以看到在滑动窗口内的 k
个数字。滑动窗口每次只向右移动一位。返回滑动窗口中的最大值。
输入:nums = [1,3,-1,-3,5,3,6,7], k = 3
输出:[3,3,5,5,6,7]
这是使用单调队列的经典题目。难点是如何求一个区间里的最大值呢?暴力方法,遍历一遍的过程中每次从窗口中再找到最大的数值,这样很明显是O(n × k)的算法。
有的同学可能会想用一个大顶堆(优先级队列)来存放这个窗口里的k个数字,这样就可以知道最大的最大值是多少了, 但是问题是这个窗口是移动的,而大顶堆每次只能弹出最大值,我们无法移除其他数值,这样就造成大顶堆维护的不是滑动窗口里面的数值了。所以不能用大顶堆。
此时我们需要一个队列,这个队列呢,放进去窗口里的元素,然后随着窗口的移动,队列也一进一出,每次移动之后,队列告诉我们里面的最大值是什么。
其实队列没有必要维护窗口里的所有元素,只需要维护有可能成为窗口里最大值的元素就可以了,同时保证队列里的元素数值是由大到小的。
那么这个维护元素单调递减的队列就叫做单调队列,即单调递减或单调递增的队列。C++中没有直接支持单调队列,需要我们自己来实现一个单调队列
不要以为实现的单调队列就是 对窗口里面的数进行排序,如果排序的话,那和优先级队列又有什么区别了呢。

对于窗口里的元素{2, 3, 5, 1 ,4}
,单调队列里只维护{5, 4}
就够了,保持单调队列里单调递减,此时队列出口元素就是窗口里最大元素。此时大家应该怀疑单调队列里维护着{5, 4}
怎么配合窗口进行滑动呢?
设计单调队列的时候,pop,和push操作要保持如下规则:
pop(value)
:如果窗口移除的元素value等于单调队列的出口元素,那么队列弹出元素,否则不用任何操作push(value)
:如果push
的元素value
大于入口元素的数值,那么就将队列入口的元素弹出,直到push
元素的数值小于等于队列入口元素的数值为止
保持如上规则,每次窗口移动的时候,只要问que.front()
就可以返回当前窗口的最大值。
为了更直观的感受到单调队列的工作过程,以题目示例为例,输入: nums = [1,3,-1,-3,5,3,6,7]
, 和 k = 3
,动画如下:

1 | # 解法一:使用自定义的单调队列类 |
1 | # 解法二:直接用单调队列 |
- 时间复杂度: O(n)
- 空间复杂度: O(k)
前 K 个高频元素
给定一个非空的整数数组,返回其中出现频率前 k 高的元素。示例:
- 输入:
nums = [1,1,1,2,2,3], k = 2
- 输出:
[1,2]
提示:
- 你可以假设给定的
k
总是合理的,且1 ≤ k ≤ 数组中不相同的元素的个数
。 - 你的算法的时间复杂度必须优于 $O(n \log n)$ ,
n
是数组的大小。 - 题目数据保证答案唯一,换句话说,数组中前
k
个高频元素的集合是唯一的。 - 你可以按任意顺序返回答案。
这道题目主要涉及到如下三块内容:
- 要统计元素出现频率
- 对频率排序
- 找出前
K
个高频元素
首先统计元素出现的频率,这一类的问题可以使用map
来进行统计。然后是对频率进行排序,这里我们可以使用一种 容器适配器就是优先级队列。
其实就是一个披着队列外衣的堆,因为优先级队列对外接口只是从队头取元素,从队尾添加元素,再无其他取元素的方式,看起来就是一个队列。而且优先级队列内部元素是自动依照元素的权值排列。
堆是一棵完全二叉树,树中每个结点的值都不小于(或不大于)其左右孩子的值。 如果父亲结点是大于等于左右孩子就是大顶堆,小于等于左右孩子就是小顶堆。
所以大家经常说的大顶堆(堆头是最大元素),小顶堆(堆头是最小元素),如果懒得自己实现的话,就直接用priority_queue(优先级队列)就可以了,底层实现都是一样的,从小到大排就是小顶堆,从大到小排就是大顶堆。
此时要思考一下,是使用小顶堆呢,还是大顶堆?有的同学一想,题目要求前 K 个高频元素,那么果断用大顶堆啊。那么问题来了,定义一个大小为k的大顶堆,在每次移动更新大顶堆的时候,每次弹出都把最大的元素弹出去了,那么怎么保留下来前K个高频元素呢。
所以我们要用小顶堆,因为要统计最大前k个元素,只有小顶堆每次将最小的元素弹出,最后小顶堆里积累的才是前k
个最大元素。寻找前k
个最大元素流程如图所示:(图中的频率只有三个,所以正好构成一个大小为3的小顶堆,如果频率更多一些,则用这个小顶堆进行扫描)

大家对这个比较运算在建堆时是如何应用的,为什么左大于右就会建立小顶堆,反而建立大顶堆比较困惑。
确实 例如我们在写快排的cmp函数的时候,return left>right
就是从大到小,return left<right
就是从小到大。
优先级队列的定义正好反过来了,可能和优先级队列的源码实现有关(我没有仔细研究),我估计是底层实现上优先队列队首指向后面,队尾指向最前面的缘故!
1 | import heapq |
- 时间复杂度: O(nlogk)
- 空间复杂度: O(n)
1 | class Solution: |
非线性结构
树
定义:树是一种层次结构的数据结构,由节点和边组成。
基本术语:
- 根节点:树的顶层节点。
- 子节点:一个节点的直接下级节点。
- 父节点:一个节点的直接上级节点。
- 叶子节点:没有子节点的节点。
- 深度:从根节点到当前节点的路径长度。
- 高度:从当前节点到叶子节点的最长路径长度。
类型:
- 二叉树:每个节点最多有两个子节点。
- 二叉搜索树(BST):左子节点的值小于父节点,右子节点的值大于父节点。
- 平衡二叉树(AVL树):二叉搜索树的一种,通过旋转保持平衡。
- 红黑树:一种自平衡二叉搜索树,通过颜色标记保持平衡。
- B树和B+树:多路平衡搜索树,常用于数据库和文件系统。
- 堆:一种特殊的树结构,分为最大堆和最小堆,用于实现优先队列。
特点:**树的查找、插入和删除操作的时间复杂度通常为O(log n)**。
应用:适用于需要层次化存储和快速查找的场景,如文件系统、数据库索引等。
二叉树:
解题过程中二叉树有两种主要的形式:满二叉树和完全二叉树。
满二叉树:如果一棵二叉树只有度为0的结点和度为2的结点,并且度为0的结点在同一层上,则这棵二叉树为满二叉树。
完全二叉树:在完全二叉树中,除了最底层节点可能没填满外,其余每层节点数都达到最大值,并且最下面一层的节点都集中在该层最左边的若干位置。若最底层为第 h 层(h从1开始),则该层包含 1~ 2^(h-1) 个节点。
优先级队列其实是一个堆,堆就是一棵完全二叉树,同时保证父子节点的顺序关系。
二叉搜索树:
前面介绍的树,都没有数值的,而二叉搜索树是有数值的了,二叉搜索树是一个有序树。
- 若它的左子树不空,则左子树上所有结点的值均小于它的根结点的值;
- 若它的右子树不空,则右子树上所有结点的值均大于它的根结点的值;
- 它的左、右子树也分别为二叉排序树
下面这两棵树都是搜索树

平衡二叉搜索树:
又被称为AVL(Adelson-Velsky and Landis)树,且具有以下性质:它是一棵空树或它的左右两个子树的高度差的绝对值不超过1,并且左右两个子树都是一棵平衡二叉树。

C++中map、set、multimap,multiset的底层实现都是平衡二叉搜索树,所以map、set的增删操作时间时间复杂度是logn,注意我这里没有说unordered_map、unordered_set,unordered_map、unordered_set底层实现是哈希表。
所以大家使用自己熟悉的编程语言写算法,一定要知道常用的容器底层都是如何实现的,最基本的就是map、set等等,否则对其性能分析不清楚!
二叉树的存储方式:
二叉树可以链式存储,也可以顺序存储。那么链式存储方式就用指针, 顺序存储的方式就是用数组。顾名思义就是链式存储则是通过指针把分布在各个地址的节点串联一起,而顺序存储的元素在内存是连续分布的。
链式存储:

顺序存储:

用数组来存储二叉树如何遍历的呢?如果父节点的数组下标是 i,那么它的左孩子就是 i * 2 + 1,右孩子就是 i * 2 + 2。但是用链式表示的二叉树,更有利于我们理解,所以一般我们都是用链式存储二叉树
二叉树的遍历方式:
- 深度优先遍历:先往深走,遇到叶子节点再往回走。
- 前序遍历(递归法,迭代法)
- 中序遍历(递归法,迭代法)
- 后序遍历(递归法,迭代法)
- 广度优先遍历:一层一层的去遍历。
- 层次遍历(迭代法)
在深度优先遍历中:有三个顺序,前中后序遍历, 这里前中后,其实指的就是中间节点的遍历顺序,只要大家记住 前中后序指的就是中间节点的位置就可以了。看如下中间节点的顺序,就可以发现,中间节点的顺序就是所谓的遍历方式
- 前序遍历:中左右
- 中序遍历:左中右
- 后序遍历:左右中

最后再说一说二叉树中深度优先和广度优先遍历实现方式,我们做二叉树相关题目,经常会使用递归的方式来实现深度优先遍历,也就是实现前中后序遍历,使用递归是比较方便的。
之前我们讲栈与队列的时候,就说过栈其实就是递归的一种实现结构,也就说前中后序遍历的逻辑其实都是可以借助栈使用递归的方式来实现的。而广度优先遍历的实现一般使用队列来实现,这也是队列先进先出的特点所决定的,因为需要先进先出的结构,才能一层一层的来遍历二叉树。
二叉树的定义
刚刚我们说过了二叉树有两种存储方式顺序存储和链式存储,顺序存储就是用数组来存,我们来看看链式存储的二叉树节点的定义方式:
1 | class TreeNode: |
大家会发现二叉树的定义 和链表是差不多的,相对于链表 ,二叉树的节点里多了一个指针, 有两个指针,指向左右孩子。这里要提醒大家要注意二叉树节点定义的书写方式。
在现场面试的时候 面试官可能要求手写代码,所以数据结构的定义以及简单逻辑的代码一定要锻炼白纸写出来。
二叉树的递归遍历
递归算法的三个要素。每次写递归,都按照这三要素来写
- 确定递归函数的参数和返回值: 确定哪些参数是递归的过程中需要处理的,那么就在递归函数里加上这个参数, 并且还要明确每次递归的返回值是什么进而确定递归函数的返回类型。
- 确定终止条件: 写完了递归算法, 运行的时候,经常会遇到栈溢出的错误,就是没写终止条件或者终止条件写的不对,操作系统也是用一个栈的结构来保存每一层递归的信息,如果递归没有终止,操作系统的内存栈必然就会溢出。
- 确定单层递归的逻辑: 确定每一层递归需要处理的信息。在这里也就会重复调用自己来实现递归的过程。
以下以前序遍历为例:
- 确定递归函数的参数和返回值:因为要打印出前序遍历节点的数值,所以参数里需要传入vector来放节点的数值,除了这一点就不需要再处理什么数据了也不需要有返回值,所以递归函数返回类型就是void,代码如下:
1 | void traversal(TreeNode* cur, vector<int>& vec) |
- 确定终止条件:在递归的过程中,如何算是递归结束了呢,当然是当前遍历的节点是空了,那么本层递归就要结束了,所以如果当前遍历的这个节点是空,就直接return,代码如下:
1 | if (cur == NULL) return; |
- 确定单层递归的逻辑:前序遍历是中左右的顺序,所以在单层递归的逻辑,是要先取中节点的数值,代码如下:
1 | vec.push_back(cur->val); // 中 |
单层递归的逻辑就是按照中左右的顺序来处理的,这样二叉树的前序遍历,基本就写完了,再看一下完整代码:
前序遍历:
1 | # Definition for a binary tree node. |
那么前序遍历写出来之后,中序和后序遍历就不难理解了,代码如下:
中序遍历:
1 | # 中序遍历-递归-LC94_二叉树的中序遍历 |
后序遍历:
1 | # 后序遍历-递归-LC145_二叉树的后序遍历 |
二叉树的迭代遍历
递归的实现就是:每一次递归调用都会把函数的局部变量、参数值和返回地址等压入调用栈中,然后递归返回的时候,从栈顶弹出上一次递归的各项参数。
前序遍历(迭代法)
前序遍历是中左右,每次先处理的是中间节点,那么先将根节点放入栈中,然后将右孩子加入栈,再加入左孩子。为什么要先加入 右孩子,再加入左孩子呢? 因为这样出栈的时候才是中左右的顺序。
动画如下:
.gif)
不难写出如下代码: (注意代码中空节点不入栈)
1 | # 前序遍历-迭代-LC144_二叉树的前序遍历 |
中序遍历(迭代法)
为了解释清楚,我说明一下 刚刚在迭代的过程中,其实我们有两个操作:
- 处理:将元素放进result数组中
- 访问:遍历节点
为什么刚刚写的前序遍历的代码不能和中序遍历通用呢,因为前序遍历的顺序是中左右,先访问的元素是中间节点,要处理的元素也是中间节点,所以刚刚才能写出相对简洁的代码,因为要访问的元素和要处理的元素顺序是一致的,都是中间节点。
中序遍历是左中右,先访问的是二叉树顶部的节点,然后一层一层向下访问,直到到达树左面的最底部,再开始处理节点(也就是在把节点的数值放进result数组中),这就造成了处理顺序和访问顺序是不一致的。
那么在使用迭代法写中序遍历,就需要借用指针的遍历来帮助访问节点,栈则用来处理节点上的元素。动画如下:
.gif)
1 | # 中序遍历-迭代-LC94_二叉树的中序遍历 |
后序遍历(迭代法)
先序遍历是中左右,后序遍历是左右中,那么我们只需要调整一下先序遍历的代码顺序,就变成中右左的遍历顺序,然后在反转result数组,输出的结果顺序就是左右中了,如下图:

后序遍历只需要前序遍历的代码稍作修改就可以了,代码如下:
1 | # 后序遍历-迭代-LC145_二叉树的后序遍历 |
前序遍历中访问节点(遍历节点)和处理节点(将元素放进result数组中)可以同步处理,但是中序就无法做到同步!
二叉树的统一迭代法
接下来介绍一下统一写法:我们以中序遍历为例,使用栈的话,无法同时解决访问节点(遍历节点)和处理节点(将元素放进结果集)不一致的情况。
那我们就将访问的节点放入栈中,把要处理的节点也放入栈中但是要做标记。如何标记呢?
方法一:就是要处理的节点放入栈之后,紧接着放入一个空指针作为标记。 这种方法可以叫做空指针标记法。
方法二:加一个 boolean 值跟随每个节点,false (默认值) 表示需要为该节点和它的左右儿子安排在栈中的位次,true 表示该节点的位次之前已经安排过了,可以收割节点了。 这种方法可以叫做boolean 标记法,样例代码见下文C++ 和 Python 的 boolean 标记法。 这种方法更容易理解,在面试中更容易写出来。
迭代法 前序遍历(空指针标记法):
注意此时我们和中序遍历相比仅仅改变了两行代码的顺序
1 | class Solution: |
迭代法 中序遍历(空指针标记法):
1 | class Solution: |
看代码有点抽象我们来看一下动画(中序遍历):
.gif)
动画中,result数组就是最终结果集。可以看出我们将访问的节点直接加入到栈中,但如果是处理的节点则后面放入一个空节点, 这样只有空节点弹出的时候,才将下一个节点放进结果集。
迭代法 后序遍历(空指针标记法):
1 | class Solution: |
中序遍历(boolean 标记法):
1 | class Solution: |
迭代法后序遍历(boolean 标记法):
1 | class Solution: |
此时我们写出了统一风格的迭代法,不用在纠结于前序写出来了,中序写不出来的情况了。但是统一风格的迭代法并不好理解,而且想在面试直接写出来还有难度的。
所以大家根据自己的个人喜好,对于二叉树的前中后序遍历,选择一种自己容易理解的递归和迭代法。
二叉树层序遍历
给你一个二叉树,请你返回其按 层序遍历 得到的节点值。 (即逐层地,从左到右访问所有节点)。
输入:[3,9,20,null,null,15,7]

输出:[[3],[9,20],[15,7]]
接下来我们再来介绍二叉树的另一种遍历方式:层序遍历。
层序遍历一个二叉树。就是从左到右一层一层的去遍历二叉树。需要借用一个辅助数据结构即队列来实现,队列先进先出,符合一层一层遍历的逻辑,而用栈先进后出适合模拟深度优先遍历也就是递归的逻辑。
而这种层序遍历方式就是图论中的广度优先遍历,只不过我们应用在二叉树上。使用队列实现二叉树广度优先遍历,动画如下:

这样就实现了层序从左到右遍历二叉树。代码如下:这份代码也可以作为二叉树层序遍历的模板。
1 | # 利用长度法 |
1 | #递归法 |
翻转二叉树
翻转一棵二叉树。
输入:[4,2,7,1,3,6,9]

输出:[4,7,2,9,6,3,1]

可以发现想要翻转它,其实就把每一个节点的左右孩子交换一下就可以了。关键在于遍历顺序,前中后序应该选哪一种遍历顺序? (一些同学这道题都过了,但是不知道自己用的是什么顺序)遍历的过程中去翻转每一个节点的左右孩子就可以达到整体翻转的效果。
注意只要把每一个节点的左右孩子翻转一下,就可以达到整体翻转的效果。这道题目使用前序遍历和后序遍历都可以,唯独中序遍历不方便,因为中序遍历会把某些节点的左右孩子翻转了两次!
那么层序遍历可以不可以呢?依然可以的!只要把每一个节点的左右孩子翻转一下的遍历方式都是可以的!
递归法
对于二叉树的递归法的前中后序遍历,已经在二叉树:前中后序递归遍历 (opens new window)详细讲解了。我们下文以前序遍历为例,通过动画来看一下翻转的过程:

我们来看一下递归三部曲:
- 确定递归函数的参数和返回值
参数就是要传入节点的指针,不需要其他参数了,通常此时定下来主要参数,如果在写递归的逻辑中发现还需要其他参数的时候,随时补充。
返回值的话其实也不需要,但是题目中给出的要返回root节点的指针,可以直接使用题目定义好的函数,所以就函数的返回类型为TreeNode*
。
1 | TreeNode* invertTree(TreeNode* root) |
- 确定终止条件
当前节点为空的时候,就返回
1 | if (root == NULL) return root; |
- 确定单层递归的逻辑
因为是前序遍历,所以先进行交换左右孩子节点,然后反转左子树,反转右子树。
1 | swap(root->left, root->right); |
基于这递归三步法,代码基本写完:
1 | # Definition for a binary tree node. |
迭代法
1 | # 前序遍历 |
1 | # 中序遍历 |
1 | # 后序遍历 |
迭代法:广度优先遍历(层序遍历)
也就是层序遍历,层数遍历也是可以翻转这棵树的,因为层序遍历也可以把每个节点的左右孩子都翻转一遍,代码如下:
1 | # Definition for a binary tree node. |
对称二叉树
给定一个二叉树,检查它是否是镜像对称的。
例1:二叉树[1,2,2,3,4,4,3] 对称

例2:二叉树[1,2,2,null,3,null,3] 不对称

比较的是两个子树的里侧和外侧的元素是否相等。如图所示:

本题遍历只能是“后序遍历”,因为我们要通过递归函数的返回值来判断两个子树的内侧节点和外侧节点是否相等。
正是因为要遍历两棵树而且要比较内侧和外侧节点,所以准确的来说是一个树的遍历顺序是左右中,一个树的遍历顺序是右左中。但都可以理解算是后序遍历,尽管已经不是严格上在一个树上进行遍历的后序遍历了。
递归法
- 确定递归函数的参数和返回值
因为我们要比较的是根节点的两个子树是否是相互翻转的,进而判断这个树是不是对称树,所以要比较的是两个树,参数自然也是左子树节点和右子树节点。返回值自然是bool类型。
1 | bool compare(TreeNode* left, TreeNode* right) |
- 确定终止条件
要比较两个节点数值相不相同,首先要把两个节点为空的情况弄清楚!否则后面比较数值的时候就会操作空指针了。
节点为空的情况有:(注意我们比较的其实不是左孩子和右孩子,所以如下我称之为左节点右节点)
- 左节点为空,右节点不为空,不对称,return false
- 左不为空,右为空,不对称 return false
- 左右都为空,对称,返回true
此时已经排除掉了节点为空的情况,那么剩下的就是左右节点不为空:
- 左右都不为空,比较节点数值,不相同就return false
此时左右节点不为空,且数值也不相同的情况我们也处理了。
1 | if (left == NULL && right != NULL) return false; |
注意上面最后一种情况,我没有使用else,而是else if, 因为我们把以上情况都排除之后,剩下的就是 左右节点都不为空,且数值相同的情况。
- 确定单层递归的逻辑
此时才进入单层递归的逻辑,单层递归的逻辑就是处理 左右节点都不为空,且数值相同的情况。
- 比较二叉树外侧是否对称:传入的是左节点的左孩子,右节点的右孩子。
- 比较内侧是否对称,传入左节点的右孩子,右节点的左孩子。
- 如果左右都对称就返回true ,有一侧不对称就返回false 。
1 | bool outside = compare(left->left, right->right); // 左子树:左、 右子树:右 |
如上代码中,我们可以看出使用的遍历方式,左子树左右中,右子树右左中,所以我把这个遍历顺序也称之为“后序遍历”(尽管不是严格的后序遍历)。
迭代法
这道题目我们也可以使用迭代法,但要注意,这里的迭代法可不是前中后序的迭代写法,因为本题的本质是判断两个树是否是相互翻转的,其实已经不是所谓二叉树遍历的前中后序的关系了。
这里我们可以使用队列来比较两个树(根节点的左右子树)是否相互翻转,(注意这不是层序遍历)
1 | class Solution: |
- 使用队列
通过队列来判断根节点的左子树和右子树的内侧和外侧是否相等,如动画所示:

如下的条件判断和递归的逻辑是一样的。代码如下:
1 | import collections |
- 使用栈
迭代法其实是把左右两个子树要比较的元素顺序放进一个容器,然后成对成对的取出来进行比较,那么其实使用栈也是可以的。只要把队列原封不动的改成栈就可以了。
1 | class Solution: |
在迭代法中我们使用了队列,需要注意的是这不是层序遍历,而且仅仅通过一个容器来成对的存放我们要比较的元素,知道这一本质之后就发现,用队列,用栈,甚至用数组,都是可以的。
1 | # 层次遍历 |
二叉树的最大深度
给定一个二叉树,找出其最大深度。二叉树的深度为根节点到最远叶子节点的最长路径上的节点数。
说明: 叶子节点是指没有子节点的节点。
示例: 给定二叉树 [3,9,20,null,null,15,7]
返回它的最大深度 3

- 递归法
本题可以使用前序(中左右),也可以使用后序遍历(左右中),使用前序求的就是深度,使用后序求的是高度。
- 二叉树节点的深度:指从根节点到该节点的最长简单路径边的条数或者节点数(取决于深度从0开始还是从1开始)
- 二叉树节点的高度:指从该节点到叶子节点的最长简单路径边的条数或者节点数(取决于高度从0开始还是从1开始)
而根节点的高度就是二叉树的最大深度,所以本题中我们通过后序求的根节点高度来求的二叉树最大深度。我先用后序遍历(左右中)来计算树的高度。
- 确定递归函数的参数和返回值:参数就是传入树的根节点,返回就返回这棵树的深度,所以返回值为int类型。
1 | int getdepth(TreeNode* node) |
- 确定终止条件:如果为空节点的话,就返回0,表示高度为0。
1 | if (node == NULL) return 0; |
- 确定单层递归的逻辑:先求它的左子树的深度,再求右子树的深度,最后取左右深度最大的数值 再+1 (加1是因为算上当前中间节点)就是目前节点为根节点的树的深度。
1 | int leftdepth = getdepth(node->left); // 左 |
代码如下:
1 | class Solution: |
1 | # 精简代码 |
- 迭代法
使用迭代法的话,使用层序遍历是最为合适的,因为最大的深度就是二叉树的层数,和层序遍历的方式极其吻合。
在二叉树中,一层一层的来遍历二叉树,记录一下遍历的层数就是二叉树的深度,如图所示:

所以这道题的迭代法就是一道模板题,可以使用二叉树层序遍历的模板来解决的。
1 | # Definition for a binary tree node. |
二叉树的最小深度
给定一个二叉树,找出其最小深度。最小深度是从根节点到最近叶子节点的最短路径上的节点数量。
说明: 叶子节点是指没有子节点的节点。示例:
给定二叉树 [3,9,20,null,null,15,7]
返回它的最小深度 2
.

本题依然是前序遍历和后序遍历都可以,前序求的是深度,后序求的是高度。
- 二叉树节点的深度:指从根节点到该节点的最长简单路径边的条数或者节点数(取决于深度从0开始还是从1开始)
- 二叉树节点的高度:指从该节点到叶子节点的最长简单路径边的条数或者节点数(取决于高度从0开始还是从1开始)
那么使用后序遍历,其实求的是根节点到叶子节点的最小距离,就是求高度的过程,不过这个最小距离 也同样是最小深度。以下讲解中遍历顺序上依然采用后序遍历(因为要比较递归返回之后的结果,本文我也给出前序遍历的写法)。
本题还有一个误区,在处理节点的过程中,最大深度很容易理解,最小深度就不那么好理解,如图:

这就重新审题了,题目中说的是:最小深度是从根节点到最近叶子节点的最短路径上的节点数量。\注意是**叶子节点**。
什么是叶子节点,左右孩子都为空的节点才是叶子节点!
- 递归法
- 确定递归函数的参数和返回值
参数为要传入的二叉树根节点,返回的是int类型的深度。
1 | int getDepth(TreeNode* node) |
- 确定终止条件
终止条件也是遇到空节点返回0,表示当前节点的高度为0。
1 | if (node == NULL) return 0; |
- 确定单层递归的逻辑
这块和求最大深度可就不一样了,一些同学可能会写如下代码:
1 | int leftDepth = getDepth(node->left); |
这个代码就犯了此图中的误区:

如果这么求的话,没有左孩子的分支会算为最短深度。所以,如果左子树为空,右子树不为空,说明最小深度是 1 + 右子树的深度。反之,右子树为空,左子树不为空,最小深度是 1 + 左子树的深度。 最后如果左右子树都不为空,返回左右子树深度最小值 + 1 。
代码如下:
1 | int leftDepth = getDepth(node->left); // 左 |
遍历的顺序为后序(左右中),可以看出:求二叉树的最小深度和求二叉树的最大深度的差别主要在于处理左右孩子不为空的逻辑。
整体递归代码如下:
1 | class Solution: |
1 | class Solution: |
精简之后的代码根本看不出是哪种遍历方式,所以依然还要强调一波:如果对二叉树的操作还不熟练,尽量不要直接照着精简代码来学。
前序遍历的方式:
1 | class Solution: |
- 迭代法
需要注意的是,只有当左右孩子都为空的时候,才说明遍历到最低点了。如果其中一个孩子不为空则不是最低点
1 | # Definition for a binary tree node. |
平衡二叉树
二叉树的所有路径
左叶子之和
找树左下角的值
路径总和
从中序与后序遍历序列构造二叉树
最大二叉树
合并二叉树
二叉搜索树中的搜索
验证二叉搜索树
二叉搜索树的最小绝对差
二叉搜索树中的众数
二叉树的最近公共祖先
二叉搜索树的最近公共祖先
二叉搜索树中的插入操作
删除二叉搜索树中的节点
修剪二叉搜索树
将有序数组转换为二叉搜索树
把二叉搜索树转换为累加树
图
- 定义:图是由节点(顶点)和边组成的非线性数据结构,用于表示网络结构。
- 类型:
- 有向图:边有方向,表示从一个节点到另一个节点的单向关系。
- 无向图:边没有方向,表示节点之间的双向关系。
- 加权图:边带有权重,表示节点之间的关系强度。
- 表示方法:
- 邻接矩阵:用二维数组表示图的连接关系*。
- 邻接表:用链表或数组表示每个节点的邻接节点*。
- 遍历算法:
- 深度优先搜索(DFS):沿着一条路径尽可能深入,直到无法继续为止,然后回溯。
- 广度优先搜索(BFS):从起点开始,逐层遍历所有相邻节点。
- 最短路径算法:
- Dijkstra算法:用于求解单源最短路径,适用于加权图。
- Floyd-Warshall算法:用于求解所有节点对之间的最短路径。
- 最小生成树算法:
- Kruskal算法:通过选择权重最小的边来构造最小生成树。
- Prim算法:通过逐步添加节点来构造最小生成树。
- 应用:适用于网络分析、社交网络、路径规划等场景。
散列表(哈希表)
定义:散列表是一种通过哈希函数将键映射到表中一个位置以实现快速访问的数据结构。哈希表中关键码就是数组的索引下标,然后通过下标直接访问数组中的元素,如下图所示:
一般哈希表都是用来快速判断一个元素是否出现集合里。要枚举的话时间复杂度是O(n),但如果使用哈希表的话, 只需要O(1)就可以做到。
哈希函数:将键转换为索引的函数,理想情况下应均匀分布键以避免冲突。哈希函数如下图所示,通过hashCode把名字转化为数值,一般hashcode是通过特定编码方式,可以将其他数据格式转化为不同的数值,这样就把学生名字映射为哈希表上的索引数字了。
此时为了保证映射出来的索引数值都落在哈希表上,我们会在再次对数值做一个取模的操作,这样我们就保证了学生姓名一定可以映射到哈希表上了。
哈希碰撞:
如果学生的数量大于哈希表的大小怎么办,此时就算哈希函数计算的再均匀,也避免不了会有几位学生的名字同时映射到哈希表 同一个索引下标的位置。
如图所示,小李和小王都映射到了索引下标 1 的位置,这一现象叫做哈希碰撞。
一般哈希碰撞有两种解决方法, 拉链法和线性探测法:
拉链法(链地址法):
发生冲突的元素都被存储在链表中, 这样我们就可以通过索引找到小李和小王了。
其实拉链法就是要选择适当的哈希表的大小,这样既不会因为数组空值而浪费大量内存,也不会因为链表太长而在查找上浪费太多时间。
线性探测法(开放地址法):
一定要保证tableSize大于dataSize。 我们需要依靠哈希表中的空位来解决碰撞问题。(数据规模是dataSize, 哈希表的大小为tableSize)
例如冲突的位置,放了小李,那么就向下找一个空位放置小王的信息。所以要求tableSize一定要大于dataSize ,要不然哈希表上就没有空置的位置来存放 冲突的数据了。如图所示:
常见的三种哈希结构:数组、set (集合)、map(映射)
特点:*查找、插入和删除操作的平均时间复杂度为O(1),最坏情况下为O(n)*。
当我们遇到了要快速判断一个元素是否出现集合里的时候,就要考虑哈希法。但是哈希法也是牺牲了空间换取了时间,因为我们要使用额外的数组,set或者是map来存放数据,才能实现快速的查找。
应用:适用于需要快速查找和插入的场景,如字典、缓存等。
有效的字母异位词
给定两个字符串 s
和 t
,编写一个函数来判断 t
是否是 s
的字母异位词。
示例 1: 输入: s = "anagram"
, t = "nagaram"
输出: true
示例 2: 输入: s = "rat"
, t = "car"
输出: false
说明: 你可以假设字符串只包含小写字母。
数组其实就是一个简单哈希表,而且这道题目中字符串只有小写字符,那么就可以定义一个数组,来记录字符串s里字符出现的次数。
需要定义一个多大的数组呢,定一个数组叫做record,大小为26 就可以了,初始化为0,因为字符a到字符z的ASCII也是26个连续的数值。
为了方便举例,判断一下字符串s= “aee”, t = “eae”。
操作动画如下:

需要把字符映射到数组也就是哈希表的索引下标上,因为字符a到字符z的ASCII是26个连续的数值,所以字符a映射为下标0,相应的字符z映射为下标25。
再遍历 字符串s的时候,只需要将 s[i] - ‘a’ 所在的元素做+1 操作即可,并不需要记住字符a的ASCII,只要求出一个相对数值就可以了。 这样就将字符串s中字符出现的次数,统计出来了。
再检查字符串t中是否出现了这些字符,同样在遍历字符串t的时候,对t中出现的字符映射哈希表索引上的数值再做-1的操作。最后如果record数组所有元素都为零0,说明字符串s和t是字母异位词,return true。
时间复杂度为O(n),空间上因为定义是的一个常量大小的辅助数组,所以空间复杂度为O(1)。
1 | class Solution: |
1 | class Solution(object): |
- 时间复杂度: O(n)
- 空间复杂度: O(1)
两个数组的交集
题意:给定两个数组,编写一个函数来计算它们的交集。
输入:nums1 = [1,2,2,1]
, nums2 = [2,2]
输出:[2]
说明: 输出结果中的每个元素一定是唯一的。 我们可以不考虑输出结果的顺序。
这道题目,主要要学会使用一种哈希数据结构:unordered_set,这个数据结构可以解决很多类似的问题。
注意题目特意说明:输出结果中的每个元素一定是唯一的,也就是说输出的结果的去重的, 同时可以不考虑输出结果的顺序
1 | class Solution: |
- 时间复杂度: O(n + m) m 是最后要把 set转成vector
- 空间复杂度: O(n)
这道题目用数组来做哈希表也是不错的选择,但是要注意,使用数组来做哈希的题目,是因为题目都限制了数值的大小。
而这道题目没有限制数值的大小,就无法使用数组来做哈希表了。如果哈希值比较少、特别分散、跨度非常大,使用数组就造成空间的极大浪费。
1 | class Solution: |
- 时间复杂度: O(m + n)
- 空间复杂度: O(n)
快乐数
编写一个算法来判断一个数 n 是不是快乐数。
「快乐数」定义为:对于一个正整数,每一次将该数替换为它每个位置上的数字的平方和,然后重复这个过程直到这个数变为 1,也可能是 无限循环 但始终变不到 1。如果 可以变为 1,那么这个数就是快乐数。
如果 n 是快乐数就返回 True
;不是,则返回 False
。
示例:
输入:19
输出:true
解释:1^2 + 9^2 = 82 8^2 + 2^2 = 68 6^2 + 8^2 = 100 1^2 + 0^2 + 0^2 = 1
这道题目看上去貌似一道数学问题,其实并不是!题目中说了会 无限循环,那么也就是说求和的过程中,sum会重复出现,这对解题很重要!**当我们遇到了要快速判断一个元素是否出现集合里的时候,就要考虑哈希法了。**
所以这道题目使用哈希法,来判断这个sum是否重复出现,如果重复了就是return false, 否则一直找到sum为1为止。还有一个难点就是求和的过程
1 | 使用集合 |
1 | 使用数组 |
- 时间复杂度: O(logn)
- 空间复杂度: O(logn)
两数之和
给定一个整数数组 nums 和一个目标值 target,请你在该数组中找出和为目标值的那 两个 整数,并返回他们的数组下标。
你可以假设每种输入只会对应一个答案。但是,数组中同一个元素不能使用两遍。
示例:
给定 nums = [2, 7, 11, 15]
, target = 9
因为 nums[0] + nums[1] = 2 + 7 = 9
所以返回 [0, 1]
因为本题,我们不仅要知道元素有没有遍历过,还要知道这个元素对应的下标,需要使用 key value结构来存放,key来存元素,value来存下标,那么使用map正合适。再来看一下使用数组和set来做哈希法的局限。
- 数组的大小是受限制的,而且如果元素很少,而哈希值太大会造成内存空间的浪费。
- set是一个集合,里面放的元素只能是一个key,而两数之和这道题目,不仅要判断y是否存在而且还要记录y的下标位置,因为要返回x 和 y的下标。所以set 也不能用。
map目的用来存放我们访问过的元素,因为遍历数组的时候,需要记录我们之前遍历过哪些元素和对应的下标,这样才能找到与当前元素相匹配的(也就是相加等于target)
这道题 我们需要 给出一个元素,判断这个元素是否出现过,如果出现过,返回这个元素的下标。
那么判断元素是否出现,这个元素就要作为key,所以数组中的元素作为key,有key对应的就是value,value用来存下标。所以 map中的存储结构为 {key:数据元素,value:数组元素对应的下标}。
在遍历数组的时候,只需要向map去查询是否有和目前遍历元素匹配的数值,如果有,就找到的匹配对,如果没有,就把目前遍历的元素放进map中,因为map存放的就是我们访问过的元素。
1 | 使用字典 |
1 | 使用集合 |
1 | 使用双指针 |
- 时间复杂度: O(n)
- 空间复杂度: O(n)
四数相加II
给定四个包含整数的数组列表 A , B , C , D ,计算有多少个元组 (i, j, k, l) ,使得 A[i] + B[j] + C[k] + D[l] = 0。
为了使问题简单化,所有的 A, B, C, D 具有相同的长度 N,且 0 ≤ N ≤ 500 。所有整数的范围在 -2^28 到 2^28 - 1 之间,最终结果不会超过 2^31 - 1 。
输入:
A = [ 1, 2]
B = [-2,-1]
C = [-1, 2]
D = [ 0, 2]
输出:2
解释:
两个元组如下:
(0, 0, 0, 1) -> A[0] + B[0] + C[0] + D[1] = 1 + (-2) + (-1) + 2 = 0
(1, 1, 0, 0) -> A[1] + B[1] + C[0] + D[0] = 2 + (-1) + (-1) + 0 = 0
本题是使用哈希法的经典题目,而0015.三数之和 (opens new window),0018.四数之和 (opens new window)并不合适使用哈希法,因为三数之和和四数之和这两道题目使用哈希法在不超时的情况下做到对结果去重是很困难的,很有多细节需要处理。
而这道题目是四个独立的数组,只要找到A[i] + B[j] + C[k] + D[l] = 0就可以,不用考虑有重复的四个元素相加等于0的情况,所以相对于题目18. 四数之和,题目15.三数之和,还是简单了不少!
本题解题步骤:
- 首先定义 一个unordered_map,key放a和b两数之和,value 放a和b两数之和出现的次数。
- 遍历大A和大B数组,统计两个数组元素之和,和出现的次数,放到map中。
- 定义int变量count,用来统计 a+b+c+d = 0 出现的次数。
- 再遍历大C和大D数组,找到如果 0-(c+d) 在map中出现过的话,就用count把map中key对应的value也就是出现次数统计出来。
- 最后返回统计值 count 就可以了
1 | class Solution(object): |
1 | 使用 defaultdict |
- 时间复杂度: O(n^2)
- 空间复杂度: O(n^2),最坏情况下A和B的值各不相同,相加产生的数字个数为 n^2
赎金信
给定一个赎金信 (ransom) 字符串和一个杂志(magazine)字符串,判断第一个字符串 ransom 能不能由第二个字符串 magazines 里面的字符构成。如果可以构成,返回 true
;否则返回 false
。
(题目说明:为了不暴露赎金信字迹,要从杂志上搜索各个需要的字母,组成单词来表达意思。杂志字符串中的每个字符只能在赎金信字符串中使用一次。)
注意:
你可以假设两个字符串均只含有小写字母。
canConstruct("a", "b") -> false canConstruct("aa", "ab") -> false canConstruct("aa", "aab") -> true
求 字符串a 和 字符串b 是否可以相互组成 ,而这道题目是求 字符串a能否组成字符串b,而不用管字符串b 能不能组成字符串a。
本题判断第一个字符串ransom能不能由第二个字符串magazines里面的字符构成,但是这里需要注意两点。
- 第一点“为了不暴露赎金信字迹,要从杂志上搜索各个需要的字母,组成单词来表达意思” 这里说明杂志里面的字母不可重复使用。
- 第二点 “你可以假设两个字符串均只含有小写字母。” 说明只有小写字母,这一点很重要
因为题目说只有小写字母,那可以采用空间换取时间的哈希策略,用一个长度为26的数组来记录magazine里字母出现的次数。然后再用ransomNote去验证这个数组是否包含了ransomNote所需要的所有字母。依然是数组在哈希法中的应用。
1 | 使用数组 |
1 | 使用字典 |
1 | 使用Counter |
- 时间复杂度: O(m+n),其中m表示ransomNote的长度,n表示magazine的长度
- 空间复杂度: O(1)
三数之和
给你一个包含 n
个整数的数组 nums
,判断 nums
中是否存在三个元素 a,b,c
,使得 a + b + c = 0
?请你找出所有满足条件且不重复的三元组。
注意: 答案中不可以包含重复的三元组。
示例:
给定数组 nums = [-1, 0, 1, 2, -1, -4]
,
满足要求的三元组集合为: [ [-1, 0, 1], [-1, -1, 2] ]
哈希解法:
两层for循环就可以确定 两个数值,可以使用哈希法来确定 第三个数 0-(a+b)
或者0 - (a + c)
是否在 数组里出现过,其实这个思路是正确的,但是我们有一个非常棘手的问题,就是题目中说的不可以包含重复的三元组。
把符合条件的三元组放进vector中,然后再去重,这样是非常费时的,很容易超时,也是这道题目通过率如此之低的根源所在。去重的过程不好处理,有很多小细节,如果在面试中很难想到位。
时间复杂度可以做到O(n^2)
,但还是比较费时的,因为不好做剪枝操作。
1 | class Solution: |
- 时间复杂度:
O(n^2)
- 空间复杂度:
O(n)
,额外的 set 开销
双指针:
这道题目使用双指针法 要比哈希法高效一些,那么来讲解一下具体实现的思路。动画效果如下:
拿这个nums
数组来举例,首先将数组排序,然后有一层for循环,i
从下标0
的地方开始,同时定一个下标left
定义在i+1
的位置上,定义下标right
在数组结尾的位置上。
依然还是在数组中找到 abc
使得a + b +c =0
,我们这里相当于 a = nums[i],b = nums[left],c = nums[right]
。
接下来如何移动left
和right
呢, 如果nums[i] + nums[left] + nums[right] > 0
就说明 此时三数之和大了,因为数组是排序后了,所以right
下标就应该向左移动,这样才能让三数之和小一些。
如果 nums[i] + nums[left] + nums[right] < 0
说明 此时 三数之和小了,left
就向右移动,才能让三数之和大一些,直到left
与right
相遇为止。
1 | class Solution: |
- 时间复杂度: O(n^2)
- 空间复杂度: O(1)
去重:
说到去重,其实主要考虑三个数的去重。 a, b ,c
对应的就是 nums[i],nums[left],nums[right]
,a
如果重复了怎么办,a
是nums
里遍历的元素,那么应该直接跳过去。
但这里有一个问题,是判断 nums[i]
与 nums[i + 1]
是否相同,还是判断 nums[i]
与 nums[i-1]
是否相同。
如果我们的写法是 这样:
1 | if nums[i] == nums[i + 1]: 去重操作 |
那我们就把 三元组中出现重复元素的情况直接pass掉了。 例如{-1, -1 ,2}
这组数据,当遍历到第一个-1
的时候,判断 下一个也是-1
,那这组数据就pass了。我们要做的是 不能有重复的三元组,但三元组内的元素是可以重复的!
所以这里是有两个重复的维度。那么应该这么写:
1 | if i > 0 && nums[i] == nums[i - 1]: |
这么写就是当前使用 nums[i]
,我们判断前一位是不是一样的元素,在看 {-1, -1 ,2}
这组数据,当遍历到 第一个 -1
的时候,只要前一位没有-1
,那么 {-1, -1 ,2}
这组数据一样可以收录到 结果集里。这是一个非常细节的思考过程。
b与c的去重
很多同学写本题的时候,去重的逻辑多加了 对right
和left
的去重:(代码中注释部分)
1 | while (right > left) { |
但细想一下,这种去重其实对提升程序运行效率是没有帮助的。
拿right
去重为例,即使不加这个去重逻辑,依然根据 while (right > left)
和 if (nums[i] + nums[left] + nums[right] > 0)
去完成right--
的操作。
多加了 while (left < right && nums[right] == nums[right + 1]) right--;
这一行代码,其实就是把 需要执行的逻辑提前执行了,但并没有减少 判断的逻辑。
最直白的思考过程,就是right还是一个数一个数的减下去的,所以在哪里减的都是一样的。
所以这种去重 是可以不加的。 仅仅是 把去重的逻辑提前了而已。
四数之和
题意:给定一个包含 n
个整数的数组 nums
和一个目标值 target
,判断 nums
中是否存在四个元素 a,b,c
和 d
,使得 a + b + c + d
的值与 target
相等?找出所有满足条件且不重复的四元组。答案中不可以包含重复的四元组。
示例: 给定数组 nums = [1, 0, -1, 0, -2, 2]
,和 target = 0
。 满足要求的四元组集合为: [ [-1, 0, 0, 1], [-2, -1, 1, 2], [-2, 0, 0, 2] ]
四数之和,和三数之和 (opens new window)是一个思路,都是使用双指针法, 基本解法就是在三数之和 (opens new window)的基础上再套一层for循环。
但是有一些细节需要注意,例如: 不要判断nums[k] > target
就返回了, 可以通过 nums[i] > 0
就返回了,因为 0 已经是确定的数了,四数之和这道题目 target是任意值。比如:数组是[-4, -3, -2, -1]
,target
是-10
,不能因为-4 > -10
而跳过。但是我们依旧可以去做剪枝,逻辑变成nums[k] > target && (nums[k] >=0 || target >= 0)
就可以了。
三数之和 的双指针解法是一层for循环num[i]
为确定值,然后循环内有left
和right
下标作为双指针,找到nums[i] + nums[left] + nums[right] == 0
。
四数之和 的双指针解法是两层for循环nums[k] + nums[i]
为确定值,依然是循环内有left
和right
下标作为双指针,找出nums[k] + nums[i] + nums[left] + nums[right] == target
的情况,三数之和的时间复杂度是O(n^2) ,四数之和的时间复杂度是O(n^3) 。
双指针法就是将原本暴力O(n^3 )的解法,降为O(n^2 )的解法,四数之和的双指针解法就是将原本暴力O(n^4 )的解法,降为O(n^3)的解法。
1 | 双指针法 |
- 时间复杂度: O(n^3)
- 空间复杂度: O(1)
1 | 使用字典 |
其他题目类型
字符串
反转字符串
编写一个函数,其作用是将输入的字符串反转过来。输入字符串以字符数组 char[] 的形式给出。
不要给另外的数组分配额外的空间,你必须原地修改输入数组、使用 O(1) 的额外空间解决这一问题。
你可以假设数组中的所有字符都是 ASCII 码表中的可打印字符。
示例 1:
输入:["h","e","l","l","o"]
输出:["o","l","l","e","h"]
示例 2:
输入:["H","a","n","n","a","h"]
输出:["h","a","n","n","a","H"]
对于这道题目一些同学直接用C++里的一个库函数 reverse,调一下直接完事了, 相信每一门编程语言都有这样的库函数。如果在现场面试中,我们什么时候使用库函数,什么时候不要用库函数呢?
如果题目关键的部分直接用库函数就可以解决,建议不要使用库函数。
毕竟面试官一定不是考察你对库函数的熟悉程度, 如果使用python和java 的同学更需要注意这一点,因为python、java提供的库函数十分丰富。
如果库函数仅仅是 解题过程中的一小部分,并且你已经很清楚这个库函数的内部实现原理的话,可以考虑使用库函数。
建议大家平时在leetcode上练习算法的时候本着这样的原则去练习,这样才有助于我们对算法的理解。
在反转链表中,使用了双指针的方法。那么反转字符串依然是使用双指针的方法,只不过对于字符串的反转,其实要比链表简单一些。因为字符串也是一种数组,所以元素在内存中是连续分布,这就决定了反转链表和反转字符串方式上还是有所差异的。
对于字符串,我们定义两个指针(也可以说是索引下标),一个从字符串前面,一个从字符串后面,两个指针同时向中间移动,并交换元素。

在字符串相关的题目中,库函数对大家的诱惑力是非常大的,因为会有各种反转,切割取词之类的操作,这也是为什么字符串的库函数这么丰富的原因。
1 | class Solution: |
- 时间复杂度: O(n)
- 空间复杂度: O(1)
反转字符串II
给定一个字符串 s
和一个整数 k
,从字符串开头算起, 每计数至 2k
个字符,就反转这 2k
个字符中的前 k
个字符。
如果剩余字符少于 k
个,则将剩余字符全部反转。
如果剩余字符小于 2k
但大于或等于 k
个,则反转前 k
个字符,其余字符保持原样。
示例:
输入: s = "abcdefg", k = 2
输出: "bacdfeg"
这道题目其实也是模拟,实现题目中规定的反转规则就可以了。
一些同学可能为了处理逻辑:每隔2k个字符的前k的字符,写了一堆逻辑代码或者再搞一个计数器,来统计2k,再统计前k个字符。
其实在遍历字符串的过程中,只要让 i += (2 * k),i 每次移动 2 * k 就可以了,然后判断是否需要有反转的区间。
因为要找的也就是每2 * k 区间的起点,这样写,程序会高效很多。
所以当需要固定规律一段一段去处理字符串的时候,要想想在for循环的表达式上做做文章。
1 | class Solution: |
- 时间复杂度: O(n)
- 空间复杂度: O(1)
替换数字
定一个字符串 s
,它包含小写字母和数字字符,请编写一个函数,将字符串中的字母字符保持不变,而将每个数字字符替换为number
。
例如,对于输入字符串 "a1b2c3"
,函数应该将其转换为 "anumberbnumbercnumber"
。
对于输入字符串 "a5b"
,函数应该将其转换为 "anumberb"
如果想把这道题目做到极致,就不要只用额外的辅助空间了! (不过使用Java和Python刷题的录友,一定要使用辅助空间,因为Java和Python里的string不能修改)
首先扩充数组到每个数字字符替换成 “number” 之后的大小。
例如 字符串 “a5b” 的长度为3,那么 将 数字字符变成字符串 “number” 之后的字符串为 “anumberb” 长度为 8。

然后从后向前替换数字字符,也就是双指针法,过程如下:i指向新长度的末尾,j指向旧长度的末尾。

为什么要从后向前填充,从前向后填充不行么?从前向后填充就是O(n^2)的算法了,因为每次添加元素都要将添加元素之后的所有元素整体向后移动。
其实很多数组填充类的问题,其做法都是先预先给数组扩容带填充后的大小,然后在从后向前进行操作。这么做有两个好处:
- 不用申请新数组。
- 从后向前填充元素,避免了从前向后填充元素时,每次添加元素都要将添加元素之后的所有元素向后移动的问题。
1 | class Solution(object): |
- 时间复杂度:O(n)
- 空间复杂度:O(1)
翻转字符串里的单词
给定一个字符串,逐个翻转字符串中的每个单词。
示例 1:
输入: "the sky is blue"
输出: "blue is sky the"
示例 2:
输入: " hello world! "
输出: "world! hello"
解释: 输入字符串可以在前面或者后面包含多余的空格,但是反转后的字符不能包括。
这道题目可以说是综合考察了字符串的多种操作。
一些同学会使用split库函数,分隔单词,然后定义一个新的string字符串,最后再把单词倒序相加,那么这道题题目就是一道水题了,失去了它的意义。
所以这里我还是提高一下本题的难度:不要使用辅助空间,空间复杂度要求为O(1)。
所以解题思路如下:举个例子,源字符串为:"the sky is blue "
- 移除多余空格
"the sky is blue"
- 将整个字符串反转
"eulb si yks eht"
- 将每个单词反转
"blue is sky the"
这样我们就完成了翻转字符串里的单词。
1 | class Solution: |
- 时间复杂度: O(n)
- 空间复杂度: O(1) 或 O(n),取决于语言中字符串是否可变
右旋字符串
字符串的右旋转操作是把字符串尾部的若干个字符转移到字符串的前面。给定一个字符串 s
和一个正整数 k
,请编写一个函数,将字符串中的后面 k
个字符移到字符串的前面,实现字符串的右旋转操作。
例如,对于输入字符串 "abcdefg"
和整数 2
,函数应该将其转换为 "fgabcde"
。
输入:输入共包含两行,第一行为一个正整数 k
,代表右旋转的位数。第二行为字符串 s
,代表需要旋转的字符串。
输出:输出共一行,为进行了右旋转操作后的字符串。
为了让本题更有意义,提升一下本题难度:不能申请额外空间,只能在本串上操作。 (Java不能在字符串上修改,所以使用java一定要开辟新空间)不能使用额外空间的话,模拟在本串操作要实现右旋转字符串的功能还是有点困难的。
本题中,我们需要将字符串右移n位,字符串相当于分成了两个部分,如果n为2,符串相当于分成了两个部分,如图: (length为字符串长度)

右移n位, 就是将第二段放在前面,第一段放在后面,先不考虑里面字符的顺序,是不是整体倒叙不就行了。如图:

此时第一段和第二段的顺序是我们想要的,但里面的字符位置被我们倒叙,那么此时我们在把 第一段和第二段里面的字符再倒叙一把,这样字符顺序不就正确了。 如果:

其实,思路就是 通过 整体倒叙,把两段子串顺序颠倒,两个段子串里的的字符在倒叙一把,负负得正,这样就不影响子串里面字符的顺序了。
1 | #获取输入的数字k和字符串 |
实现 strStr()
实现 strStr()
函数。
给定一个 haystack
字符串和一个 needle
字符串,在 haystack
字符串中找出 needle
字符串出现的第一个位置 (从0开始)。如果不存在,则返回 -1
。
示例 1: 输入:haystack = "hello", needle = "ll"
输出: 2
示例 2: 输入: haystack = "aaaaa", needle = "bba"
输出: -1
说明: 当 needle
是空字符串时,我们应当返回什么值呢?这是一个在面试中很好的问题。 对于本题而言,当 needle
是空字符串时我们应当返回 0
。
KMP的经典思想就是:当出现字符串不匹配时,可以记录一部分之前已经匹配的文本内容,利用这些信息避免从头再去做匹配。
本篇将以如下顺序来讲解KMP:
KMP有什么用
KMP主要应用在字符串匹配上。
KMP的主要思想是当出现字符串不匹配时,可以知道一部分之前已经匹配的文本内容,可以利用这些信息避免从头再去做匹配了。
所以如何记录已经匹配的文本内容,是KMP的重点,也是next数组肩负的重任。
什么是前缀表
next数组就是一个前缀表(prefix table)。前缀表是用来回退的,它记录了模式串与主串(文本串)不匹配的时候,模式串应该从哪里开始重新匹配。
为了清楚地了解前缀表的来历,我们来举一个例子:
要在文本串:
aabaabaafa
中查找是否出现过一个模式串:aabaaf
。可以看出,文本串中第六个字符
b
和 模式串的第六个字符f
,不匹配了。如果暴力匹配,发现不匹配,此时就要从头匹配了。但如果使用前缀表,就不会从头匹配,而是从上次已经匹配的内容开始匹配,找到了模式串中第三个字符b
继续开始匹配。首先要知道前缀表的任务是当前位置匹配失败,找到之前已经匹配上的位置,再重新匹配,此也意味着在某个字符失配时,前缀表会告诉你下一步匹配中,模式串应该跳到哪个位置。
那么什么是前缀表:记录下标i之前(包括i)的字符串中,有多大长度的相同前缀后缀。
文章中字符串的前缀是指不包含最后一个字符的所有以第一个字符开头的连续子串。后缀是指不包含第一个字符的所有以最后一个字符结尾的连续子串。
为什么一定要用前缀表
下标5之前这部分的字符串(也就是字符串
aabaa
)的最长相等的前缀 和 后缀字符串是 子字符串aa ,因为找到了最长相等的前缀和后缀,匹配失败的位置是后缀子串的后面,那么我们找到与其相同的前缀的后面重新匹配就可以了。所以前缀表具有告诉我们当前位置匹配失败,跳到之前已经匹配过的地方的能力。
如何计算前缀表
长度为前1个字符的子串
a
,最长相同前后缀的长度为0。(注意字符串的前缀是指不包含最后一个字符的所有以第一个字符开头的连续子串;后缀是指不包含第一个字符的所有以最后一个字符结尾的连续子串。)长度为前2个字符的子串
aa
,最长相同前后缀的长度为1长度为前3个字符的子串
aab
,最长相同前后缀的长度为0。以此类推: 长度为前4个字符的子串
aaba
,最长相同前后缀的长度为1。 长度为前5个字符的子串aabaa
,最长相同前后缀的长度为2。 长度为前6个字符的子串aabaaf
,最长相同前后缀的长度为0。那么把求得的最长相同前后缀的长度就是对应前缀表的元素,如图:
可以看出模式串与前缀表对应位置的数字表示的就是:下标i之前(包括i)的字符串中,有多大长度的相同前缀后缀。
再来看一下如何利用 前缀表找到 当字符不匹配的时候应该指针应该移动的位置。如动画所示:
找到的不匹配的位置, 那么此时我们要看它的前一个字符的前缀表的数值是多少。
为什么要前一个字符的前缀表的数值呢,因为要找前面字符串的最长相同的前缀和后缀,所以要看前一位的 前缀表的数值。
前一个字符的前缀表的数值是2, 所以把下标移动到下标2的位置继续比配。 可以再反复看一下上面的动画。最后就在文本串中找到了和模式串匹配的子串了。
前缀表与next数组
next数组就可以是前缀表,但是很多实现都是把前缀表统一减一(右移一位,初始位置为-1)之后作为next数组。
为什么这么做呢,其实这并不涉及到KMP的原理,而是具体实现,next数组既可以就是前缀表,也可以是前缀表统一减一(右移一位,初始位置为-1)。
使用next数组来匹配
有了
next
数组,就可以根据next数组来 匹配文本串s
,和模式串t
了。注意next
数组是新前缀表(旧前缀表统一减一了)。匹配过程动画如下:
时间复杂度分析
其中n为文本串长度,m为模式串长度,因为在匹配的过程中,根据前缀表不断调整匹配的位置,可以看出匹配的过程是O(n),之前还要单独生成next数组,时间复杂度是O(m)。所以整个KMP算法的时间复杂度是O(n+m)的。
暴力的解法显而易见是O(n × m),所以KMP在字符串匹配中极大地提高了搜索的效率。
构造next数组
构造next数组其实就是计算模式串s,前缀表的过程。 主要有如下三步:
初始化
定义两个指针i和j,j指向前缀末尾位置,i指向后缀末尾位置。然后还要对next数组进行初始化赋值,为什么要初始化为
-1
呢,因为之前说过 前缀表要统一减一的操作仅仅是其中的一种实现,我们这里选择j
初始化为-1
,下文我还会给出j
不初始化为-1
的实现代码。next[i]
表示i
(包括i
)之前最长相等的前后缀长度(其实就是j
)所以初始化next[0] = j
。处理前后缀不相同的情况
因为j初始化为
-1
,那么i
就从1
开始,进行s[i]
与s[j+1]
的比较。所以遍历模式串s
的循环下标i
要从1
开始,如果s[i]
与s[j+1]
不相同,也就是遇到 前后缀末尾不相同的情况,就要向前回退。怎么回退呢?
next[j]
就是记录着j
(包括j
)之前的子串的相同前后缀的长度。那么s[i]
与s[j+1]
不相同,就要找j+1
前一个元素在next
数组里的值(就是next[j]
)。处理前后缀相同的情况
如果
s[i]
与s[j + 1]
相同,那么就同时向后移动i
和j
说明找到了相同的前后缀,同时还要将j
(前缀的长度)赋给next[i]
, 因为next[i]
要记录相同前后缀的长度。代码构造next数组的逻辑流程动画如下:
使用next数组来做匹配
在文本串s里 找是否出现过模式串t。定义两个下标j 指向模式串起始位置,i指向文本串起始位置。那么j初始值依然为-1,为什么呢? 依然因为next数组里记录的起始位置为-1。i就从0开始,遍历文本串。
接下来就是 s[i] 与 t[j + 1] (因为j从-1开始的) 进行比较。如果 s[i] 与 t[j + 1] 不相同,j就要从next数组里寻找下一个匹配的位置。
如果 s[i] 与 t[j + 1] 相同,那么i 和 j 同时向后移动。
如何判断在文本串s里出现了模式串t呢,如果j指向了模式串t的末尾,那么就说明模式串t完全匹配文本串s里的某个子串了。
本题要在文本串字符串中找出模式串出现的第一个位置 (从0开始),所以返回当前在文本串匹配模式串的位置i 减去 模式串的长度,就是文本串字符串中出现模式串的第一个位置。
1 | # 前缀表(减一) |
1 | # 前缀表(不减一) |
- 时间复杂度: O(n + m)
- 空间复杂度: O(m), 只需要保存字符串needle的前缀表
人生苦短,我用Python:
1 | class Solution: |
1 | class Solution: |
重复的子字符串
给定一个非空的字符串,判断它是否可以由它的一个子串重复多次构成。
示例 1:
- 输入:
"abab"
- 输出:
True
- 解释: 可由子字符串
"ab"
重复两次构成。
示例 2:
- 输入:
"aba"
- 输出:
False
暴力的解法, 就是一个for循环获取 子串的终止位置, 然后判断子串是否能重复构成字符串,又嵌套一个for循环,所以是O(n^2)的时间复杂度。
有的同学可以想,怎么一个for循环就可以获取子串吗? 至少得一个for获取子串起始位置,一个for获取子串结束位置吧。
其实我们只需要判断,以第一个字母为开始的子串就可以,所以一个for循环获取子串的终止位置就行了。 而且遍历的时候 都不用遍历结束,只需要遍历到中间位置,因为子串结束位置大于中间位置的话,一定不能重复组成字符串。
暴力的解法,这里就不详细讲解了。主要讲一讲移动匹配 和 KMP两种方法:
移动匹配
当一个字符串s:abcabc,内部由重复的子串组成,那么这个字符串的结构一定是这样的:

也就是由前后相同的子串组成。那么既然前面有相同的子串,后面有相同的子串,用 s + s
,这样组成的字符串中,后面的子串做前串,前面的子串做后串,就一定还能组成一个s
,如图:
以上证明的充分性,接下来证明必要性:
如果有一个字符串s
,在 s + s
拼接后, 不算首尾字符,如果能凑成s
字符串,说明s
一定是重复子串组成。
如图,字符串s
,图中数字为数组下标,在s + s
拼接后, 不算首尾字符,中间凑成s
字符串。 (图中数字为数组下标)


图中,因为中间拼接成了s
,根据红色框 可以知道 s[4] = s[0], s[5] = s[1], s[0] = s[2], s[1] = s[3] s[2] = s[4] ,s[3] = s[5]

s[4] = s[0] = s[2]
s[5] = s[1] = s[3]
即:s[4],s[5] = s[0],s[1] = s[2],s[3]
说明这个字符串,是由 两个字符 s[0] 和 s[1] 重复组成的!
以上 充分和必要性都证明了,所以判断字符串s是否由重复子串组成,只要两个s拼接在一起,里面还出现一个s的话,就说明是由重复子串组成。
KMP
在一个串中查找是否出现过另一个串,这是KMP的看家本领。那么寻找重复子串怎么也涉及到KMP算法了呢?
KMP算法中next数组为什么遇到字符不匹配的时候可以找到上一个匹配过的位置继续匹配,靠的是有计算好的前缀表。
前缀表里,统计了各个位置为终点字符串的最长相同前后缀的长度。
- 前缀是指不包含最后一个字符的所有以第一个字符开头的连续子串;
- 后缀是指不包含第一个字符的所有以最后一个字符结尾的连续子串
next 数组记录的就是最长相同前后缀, 如果 next[len - 1] != -1
,则说明字符串有最长相同的前后缀(就是字符串里的前缀子串和后缀子串相同的最长长度)。
最长相等前后缀的长度为:next[len - 1] + 1
。(这里的next数组是以统一减一的方式计算的,因此需要+1,数组长度为:len
。
len - (next[len - 1] + 1)
是最长相等前后缀不包含的子串的长度。
如果len % (len - (next[len - 1] + 1)) == 0
,则说明数组的长度正好可以被 最长相等前后缀不包含的子串的长度 整除 ,说明该字符串有重复的子字符串。
强烈建议大家把next数组打印出来,看看next数组里的规律,有助于理解KMP算法
如图:

next[len - 1] = 7
,next[len - 1] + 1 = 8
,8就是此时字符串asdfasdfasdf的最长相同前后缀的长度。
(len - (next[len - 1] + 1))
也就是: 12(字符串的长度) - 8(最长公共前后缀的长度) = 4, 为最长相同前后缀不包含的子串长度,4可以被 12(字符串的长度) 整除,所以说明有重复的子字符串(asdf)。
1 | # 前缀表 减一 |
1 | # 前缀表 不减一 |
- 时间复杂度: O(n)
- 空间复杂度: O(n)
人生苦短,我用Python
1 | class Solution: |
回溯算法
回溯法也可以叫做回溯搜索法,它是一种搜索的方式。回溯是递归的副产品,只要有递归就会有回溯。所以回溯函数也就是递归函数,指的都是一个函数。
虽然回溯法很难,很不好理解,但是回溯法并不是什么高效的算法。因为回溯的本质是穷举,穷举所有可能,然后选出我们想要的答案,如果想让回溯法高效一些,可以加一些剪枝的操作,但也改不了回溯法就是穷举的本质。
那么既然回溯法并不高效为什么还要用它呢?因为没得选,一些问题能暴力搜出来就不错了,撑死了再剪枝一下,还没有更高效的解法。
回溯法一般可以解决如下几种问题:
- 组合问题:N个数里面按一定规则找出k个数的集合
- 切割问题:一个字符串按一定规则有几种切割方式
- 子集问题:一个N个数的集合里有多少符合条件的子集
- 排列问题:N个数按一定规则全排列,有几种排列方式
- 棋盘问题:N皇后,解数独等等
组合是不强调元素顺序的,排列是强调元素顺序。例如:{1, 2} 和 {2, 1} 在组合上,就是一个集合,因为不强调顺序,而要是排列的话,{1, 2} 和 {2, 1} 就是两个集合了。记住组合无序,排列有序,就可以了。
回溯法解决的问题都可以抽象为树形结构,所有回溯法的问题都可以抽象为树形结构!因为回溯法解决的都是在集合中递归查找子集,集合的大小就构成了树的宽度,递归的深度就构成了树的深度。递归就要有终止条件,所以必然是一棵高度有限的树(N叉树)。
贪心算法
动态规划
算法与复杂度分析
时间复杂度
- 定义:时间复杂度表示算法执行时间随数据规模增长的变化。
- 常见时间复杂度:
- O(1)**:常数时间复杂度,表示算法的执行时间不随数据规模变化**。
- O(log n)**:对数时间复杂度,表示算法的执行时间随数据规模对数增长**。
- O(n)**:线性时间复杂度,表示算法的执行时间随数据规模线性增长**。
- O(n log n)**:线性对数*时间复杂度,常见于快速排序和归并排序*。
- O(n^2)**:平方*时间复杂度,常见于冒泡排序和选择排序*。
- O(2^n)**:指数*时间复杂度,常见于穷举算法*。
空间复杂度
- 定义:空间复杂度表示算法所需存储空间随数据规模增长的变化。
- 常见空间复杂度:
- O(1)**:常数空间复杂度,表示算法的存储空间不随数据规模变化**。
- O(n)**:线性空间复杂度,表示算法的存储空间随数据规模线性增长**。
- O(n^2)**:平方空间复杂度,表示算法的存储空间随数据规模平方增长**。
复杂度分析的意义
复杂度分析帮助我们评估算法的效率,尤其是在处理大规模数据时。通过选择合适的数据结构和算法,可以显著提高程序的性能。
实际应用
数据库索引
- B树和B+树:用于数据库索引,支持高效的数据检索和插入操作。
- 哈希索引:用于快速查找特定键值,适用于等值查询。
网络路由
- 图算法:如Dijkstra算法和Floyd-Warshall算法,用于确定数据包在网络中的传输路径。
- 路由表:通过散列表或树结构存储路由信息,支持快速查找和更新。
资源调度
- 队列和优先队列:用于管理任务执行顺序,确保高优先级任务优先执行。
- 调度算法:如最短作业优先(SJF)、轮转调度(Round Robin)等,基于队列实现。
缓存系统
- LRU缓存:使用散列表和双向链表实现最近最少使用(LRU)缓存替换策略。
- 缓存淘汰算法:如FIFO、LFU等,基于队列或堆实现。
文件系统
- 树结构:用于组织文件和目录,支持快速查找和管理。
- B树和B+树:用于文件系统的索引结构,支持高效的文件存储和检索。
结论
数据结构是计算机科学的基础,掌握各种数据结构及其应用场景对于解决复杂的计算问题至关重要。通过理论学习和实践练习,开发者可以有效地提高编程能力和算法设计技巧,从而编写出高效、可靠的程序。