1、数据结构的存储方式底层只有两种:数组(顺序存储)和链表(链式存储)
二者区别:
数组:连续存储,可以随机访问,通过索引可以快速找到对应元素,而且相对节约存储时间。正因为连续存储,必须一次性分配内存空间,扩容需要重新分配更大空间,把数据复制过去,从中间插入和删除必须移动后面的数据
链表:元素不连续,靠指针指向下一个元素位置。知道某一个节点的前驱和后驱就可以对该指针删除或者插入新元素。由于不连续,无法通过索引找到对应元素,不能随机访问,每个元素保存前后元素位置的指针,增加存储空间
2、数据结构的基本操作
数据结构基本操作无非就是遍历+访问,再具体一点就是增删改查
3、动态规划算法详解
1)动态规划一般形式就是求最值
2)动态规划三要素
- 重叠子问题
- 最优子结构
- 状态转移方程
例子:斐波那契数列
1)暴力解法
int fib(int N) {
if (N == 1 || N == 2) return 1;
return fib(N - 1) + fib(N - 2);
}
递归树图:
如果想求出f(20),就需要计算出子问题f(19)和f(18)的值,然后要计算f(19)就需要先计算子问题f(18)和f(17),以此类推。最后遇到f(1)和f(2)的时候,结果已知,直接返回结果,递归式不再向下生成。
但是上面有一个问题,那就是会有重复计算的数据,比如f(18)和f(17)都计算了两遍,下面让我们对上面进行优化
2)带备忘录的递归解法
我们创建一个“备忘录” ,每次计算子问题的答案先记到“备忘录”中,再返回,每次遇到一个子问题先去“备忘录”查询,如果发现问题已经解决,直接取答案,不需要重新计算
示例代码:
int fib(int N) {
if (N < 1) return 0;
// 备忘录全初始化为 0
vector<int> memo(N + 1, 0);
// 初始化最简情况
return helper(memo, N);
}
int helper(vector<int>& memo, int n) {
// base case
if (n == 1 || n == 2) return 1;
// 已经计算过
if (memo[n] != 0) return memo[n];
memo[n] = helper(memo, n - 1) +
helper(memo, n - 2);
return memo[n];
}
在通过递归树看一下“备忘录”的作用
以上方法都是“自顶向下”的求解方法,那么是否可以“自底向上”的求解呢?
3)dp迭代求解
示例代码:
int fib(int n) {
if (n == 2 || n == 1)
return 1;
int prev = 1, curr = 1;
for (int i = 3; i <= n; i++) {
int sum = prev + curr;
prev = curr;
curr = sum;
}
return curr;
}
看一下迭代的示例图:
状态转移方程如下:
4、回溯算法详解
解决一个回溯问题,实际就是一个决策树的遍历过程:
1)路径:已经做出的选择
2)选择列表:可以做的选择
3)结束条件:到达决策树底层,无法在做选择的条件
伪代码如下:
for (auto &selectItem : selectNum)
{
// 排除不合法的选择
if (track.contains(selectItem))
continue;
// 做选择
track.add(selectItem);
// 进⼊下⼀层决策树
backtrack(selectNum, track);
// 取消选择
track.removeLast();
}
例子:全排列
比如给三个数[1,2,3],如何全排列?穷举先固定第一位为1,然后第二位可以是2,第三位可以是3;然后第二位变成3,第三位变成2……其实这就是回溯算法
回溯树:
通过下图在理解一下棱镜、选择列表、结束条件:
示例代码:
List<List<Integer>> res = new LinkedList<>();
/* 主函数,s输入一组不重复的数字,返回它们的全排列 */
List<List<Integer>> permute(int[] nums) {
// 记录「路径」
LinkedList<Integer> track = new LinkedList<>();
backtrack(nums, track);
return res;
}
// 路径:记录在 track 中
// 选择列表:nums 中不存在于 track 的那些元素
// 结束条件:nums 中的元素全都在 track 中出现
void backtrack(int[] nums, LinkedList<Integer> track) {
// 触发结束条件
if (track.size() == nums.length) {
res.add(new LinkedList(track));
return;
}
for (int i = 0; i < nums.length; i++) {
// 排除不合法的选择
if (track.contains(nums[i]))
continue;
// 做选择
track.add(nums[i]);
// 进入下层决策树
backtrack(nums, track);
// 取消选择
track.removeLast();
}
}
5、BFS算法详解
BFS核心思想:把问题抽象成图,从一个点开始,向四周开始扩散。一般我们用队列写BFS算法,每次将一个节点周围的所有节点加入队列。
例题:二叉树的最小高度
套用BFS框架,显然root是起点,终点是两个子节点都是null的节点:
if (cur.left == null && cur.right == null)
// 到达叶⼦节点
示例代码:
int minDepth(TreeNode root) {
if (root == null) return 0;
Queue<TreeNode> q = new LinkedList<>();
q.offer(root);
// root 本来就是一层,depth 初始化为 1
int depth = 1;
while (!q.isEmpty()) {
int sz = q.size();
/* 将当前队列中的所有节点向四周扩散 */
for (int i = 0; i < sz; i++) {
TreeNode cur = q.poll();
/* 判断是否到达终点 */
if (cur.left == null && cur.right == null)
return depth;
/* 将 cur 的相邻节点加入队列 */
if (cur.left != null)
q.offer(cur.left);
if (cur.right != null)
q.offer(cur.right);
}
/* 这里增加步数 */
depth++;
}
return depth;
}
6、双指针技巧框架
1)快慢指针常用算法
a.判断链表是否有环
示例代码:
bool hasCycle(ListNode *node)
{
if (node == nullptr || node->next == nullptr)
{
return false;
}
ListNode *fastNode = node;
ListNode *slowNode = node;
while(fastNode != nullptr && fastNode->next != nullptr)
{
fastNode = fastNode->next->next;
slowNode = slowNode->next;
if (fastNode == slowNode)
{
return true;
}
}
return false;
}
b.寻找单链表倒数第k个元素
示例代码:
ListNode * Test(ListNode *node,int n)
{
if (node == nullptr || node->next == nullptr)
{
return node;
}
ListNode *fastNode = node;
ListNode *slowNode = node;
while(n-- > 0)
{
fastNode = fastNode->next;//指针先走n步
}
while(fastNode != nullptr && fastNode->next != nullptr)
{
//快慢指针一起走,fastNode和slowNode间距n,fastNode到链表尾部,slowNode指向倒数n的节点
fastNode = fastNode->next;
slowNode = slowNode->next;
}
return slowNode;
}
2)左右指针常用算法
a.二分查找
题目:给定一组有序数组,和一个目标值n,进行二分查找,找到返回数组下标,未找到返回-1
示例代码:
int binarySearch(std::vector<int> &nums,int n)
{
int left = 0;
int right = nums.size()-1;
while(left <= right)
{
int mid = left + (right - left)/2;
if (nums.at(mid) == n)//如果数组中间值等于目标值,返回mid
{
return mid;
}
if (nums.at(mid) > n) //如果数组中间值大于目标值,右区间前移到中间值前一个位置
{
right = mid - 1;
}
if (nums.at(mid) < n)//如果数组中间值小于目标值,左区间后移到中间值后一个位置
{
left = mid + 1;
}
}
return -1;
}
b.数组反转
示例代码:
void reverse(std::vector<int> &nums)
{
int left = 0;
int right = nums.size()-1;
while(left < right)
{
int nTemp = nums.at(left);
nums[left] = nums[right];
nums[right] = nums[left];
left++;
right--;
}
}
7、二分查找框架详解
1)正常二分查找(如果有多个找到一个就返回)
示例代码:见上面例子代码
2)左侧边界的二分查找(如果有多个返回第一个)
思路:找到目标值不返回,而是收缩右边边界(注意越界)
如图:
示例代码:
int left_bound(std::vector<int> &nums, int target) {
int left = 0, right = nums.size() - 1;
// 搜索区间为 [left, right]
while (left <= right) {
int mid = left + (right - left) / 2;
if (nums[mid] < target) {
// 搜索区间变为 [mid+1, right]
left = mid + 1;
} else if (nums[mid] > target) {
// 搜索区间变为 [left, mid-1]
right = mid - 1;
} else if (nums[mid] == target) {
// 收缩右侧边界
right = mid - 1;
}
}
// 检查出界情况
if (left >= nums.size() || nums[left] != target)
return -1;
return left;
}
3)左侧边界的二分查找(如果有多个返回第一个)
思路:找到目标值不返回,而是收缩左边边界(注意越界)
如图:
示例代码:
int right_bound(std::vector<int> &nums, int target) {
int left = 0, right = nums.size() - 1;
while (left <= right) {
int mid = left + (right - left) / 2;
if (nums[mid] < target) {
left = mid + 1;
} else if (nums[mid] > target) {
right = mid - 1;
} else if (nums[mid] == target) {
// 别返回,锁定右侧边界
left = mid + 1;
}
}
// 最后要检查 right 越界的情况
if (right < 0 || nums[right] != target)
return -1;
return right;
}
8、滑动窗口框架详解
适用:求解子串问题
滑动窗口算法的思路是这样:
- 我们在字符串
S
中使用双指针中的左右指针技巧,初始化left = right = 0
,把索引左闭右开区间[left, right)
称为一个「窗口」。 - 我们先不断地增加
right
指针扩大窗口[left, right)
,直到窗口中的字符串符合要求(包含了T
中的所有字符)。 - 此时,我们停止增加
right
,转而不断增加left
指针缩小窗口[left, right)
,直到窗口中的字符串不再符合要求(不包含T
中的所有字符了)。同时,每次增加left
,我们都要更新一轮结果。 - 重复第 2 和第 3 步,直到
right
到达字符串S
的尽头。
这个思路其实也不难,第 2 步相当于在寻找一个「可行解」,然后第 3 步在优化这个「可行解」,最终找到最优解,也就是最短的覆盖子串。左右指针轮流前进,窗口大小增增减减,窗口不断向右滑动,这就是「滑动窗口」这个名字的来历
代码框架:
/* 滑动窗口算法框架 */
void slidingWindow(string s, string t) {
unordered_map<char, int> need, window;
for (char c : t) need[c]++;
int left = 0, right = 0;
int valid = 0;
while (right < s.size()) {
// c 是将移入窗口的字符
char c = s[right];
// 右移窗口
right++;
// 进行窗口内数据的一系列更新
...
/*** debug 输出的位置 ***/
printf("window: [%d, %d)\n", left, right);
/********************/
// 判断左侧窗口是否要收缩
while (window needs shrink) {
// d 是将移出窗口的字符
char d = s[left];
// 左移窗口
left++;
// 进行窗口内数据的一系列更新
...
}
}
}
我们通过例子了解一下滑动窗口使用
例子:
a.最小覆盖子串
下面画图理解一下,needs
和window
相当于计数器,分别记录T
中字符出现次数和「窗口」中的相应字符的出现次数。
初始状态:
增加right
,直到窗口[left, right)
包含了T
中所有字符:
现在开始增加left
,缩小窗口[left, right):
直到窗口中的字符串不再符合要求,left
不再继续移动:
之后重复上述过程,先移动right
,再移动left
…… 直到right
指针到达字符串S
的末端,算法结束
现在开始套模板,只需要思考以下四个问题:
- 当移动
right
扩大窗口,即加入字符时,应该更新哪些数据? - 什么条件下,窗口应该暂停扩大,开始移动
left
缩小窗口? - 当移动
left
缩小窗口,即移出字符时,应该更新哪些数据? - 我们要的结果应该在扩大窗口时还是缩小窗口时进行更新?
示例代码:
string minWindow(string s, string t) {
unordered_map<char, int> need, window;
for (char c : t) need[c]++;
int left = 0, right = 0;
int valid = 0;
// 记录最小覆盖子串的起始索引及长度
int start = 0, len = INT_MAX;
while (right < s.size()) {
// c 是将移入窗口的字符
char c = s[right];
// 右移窗口
right++;
// 进行窗口内数据的一系列更新
if (need.count(c)) {
window[c]++;
if (window[c] == need[c])
valid++;
}
// 判断左侧窗口是否要收缩
while (valid == need.size()) {
// 在这里更新最小覆盖子串
if (right - left < len) {
start = left;
len = right - left;
}
// d 是将移出窗口的字符
char d = s[left];
// 左移窗口
left++;
// 进行窗口内数据的一系列更新
if (need.count(d)) {
if (window[d] == need[d])
valid--;
window[d]--;
}
}
}
// 返回最小覆盖子串
return len == INT_MAX ?
"" : s.substr(start, len);
}
b.找所有字母异位词
示例代码:
vector<int> findAnagrams(string s, string t) {
unordered_map<char, int> need, window;
for (char c : t) need[c]++;
int left = 0, right = 0;
int valid = 0;
vector<int> res; // 记录结果
while (right < s.size()) {
char c = s[right];
right++;
// 进行窗口内数据的一系列更新
if (need.count(c)) {
window[c]++;
if (window[c] == need[c])
valid++;
}
// 判断左侧窗口是否要收缩
while (right - left >= t.size()) {
// 当窗口符合条件时,把起始索引加入 res
if (valid == need.size())
res.push_back(left);
char d = s[left];
left++;
// 进行窗口内数据的一系列更新
if (need.count(d)) {
if (window[d] == need[d])
valid--;
window[d]--;
}
}
}
return res;
}