数据结构和算法爆肝三万字你必须知道的20个解决问题的技巧
Posted 海拥✘
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了数据结构和算法爆肝三万字你必须知道的20个解决问题的技巧相关的知识,希望对你有一定的参考价值。
我的上一篇关于数据结构和算法的文章:《30 个重要数据结构和算法完整介绍》获得了八百多的收藏和近两百的评论,因此我也有了写这篇文章的动力。在本文中,我将深入探讨 20 种解决问题的技巧,您必须知道这些技巧才能在学习、面试、工作中脱颖而出。
我将这些技术归为一组:
- 基于指针
- 基于递归
- 排序和搜索
- 扩展基本数据结构
- 杂项
我将解释它们中的每一个,展示如何将它们应用于编码问题,并为您留下一些练习,以便您可以自己练习。
基于指针的技术
1. 两个指针
这种技术对于排序数组和我们想要对其元素进行分组的数组非常有用。
这个想法是使用两个(或多个指针)根据某些条件将数组拆分为不同的区域或组:
- 小于、等于和大于某个值的元素
- 总和过小或过大的元素
- 等等
下面的例子将帮助你理解这个原理。
二和
给定一个已经按升序排序的整数数组,找到两个数字,使它们相加为特定的目标数字。函数 twoSum 应该返回两个数字的索引,使它们相加为目标,其中 index1 必须小于 index2。
笔记:
- 您返回的答案(index1 和 index2)不是从零开始的。
- 您可以假设每个输入都只有一个解决方案,并且您不能两次使用相同的元素。
例子:
- 输入:数字 = [2,7,11,15],目标 = 9
- 输出:[1,2]
- 解释:2 和 7 的和是 9。因此 index1 = 1,index2 = 2。
解决方案
由于数组a已排序,我们知道:
- 最大的总和等于最后 2 个元素的总和
- 最小的总和等于前 2 个元素的总和
- 对于[0, a.size() - 1) => a[i + 1] >= a[i] 中的任何索引i
有了这个,我们可以设计以下算法:
- 我们保留了 2 个指针:l,从数组的第一个元素开始,而r从数组的最后一个元素开始。
- 如果 a[l] + a[r] 的总和小于我们的目标,我们将 l 加一(将加法中的最小操作数更改为另一个等于或大于l+1的操作数);如果它大于目标,我们将 r 减一(将我们的最大操作数更改为另一个等于或小于r-1 的操作数)。
- 我们这样做直到 a[l] + a[r] 等于我们的目标,或者 l 和 r
指向同一个元素(因为我们不能两次使用同一个元素)或者已经交叉,表明没有解决方案。
这是一个简单的 C++ 实现:
vector<int> twoSum(const vector<int>& a, int target) {
int l = 0, r = a.size() - 1;
vector<int> sol;
while(l < r) {
const int sum = a[l] + a[r];
if(target == sum){
sol.push_back(l + 1);
sol.push_back(r + 1);
break;
} else if (target > sum) {
++l;
} else {
--r;
}
}
return sol;
}
时间复杂度是 O(N),因为我们可能需要遍历数组的 N 个元素才能找到解。
空间复杂度是 O(1),因为我们只需要两个指针,而不管数组包含多少个元素。
还有其他方法可以解决这个问题(例如,使用哈希表),但我使用它只是作为两个指针技术的说明。
挑战
以下是此练习的两种变体:三和和四和。可以通过将它们简化为相同的问题来类似地解决它们。
这是一种非常常见的技术:将您不知道其解决方案的问题转化为您可以解决的问题。
从数组中删除重复项
给定一个排序数组 nums,就地删除重复项,使每个元素只出现一次并返回新长度。
不要为另一个数组分配额外的空间,您必须通过使用 O(1) 额外内存就地修改输入数组来实现。
示例 1:
- 给定 nums = [1,1,2],
- 输出 = 2
示例 2:
- 给定 nums = [0,0,1,1,1,2,2,3,3,4],
- 输出 = 5
在返回的长度之外设置什么值并不重要。
解决方案
数组已排序,我们希望将重复项移动到数组的末尾,这听起来很像基于某些条件分组。你将如何使用两个指针解决这个问题?
- 您将需要一个指针来遍历数组i。
- 还有第二个指针n,用于定义不包含重复项的区域:[0,n]。
逻辑如下。如果索引i(i = 0除外)和i-1处元素的值为:
- 同样,我们什么都不做——这个副本将被a 中的下一个唯一元素覆盖。
- 不同:我们将a[i]添加到不包含重复项的数组部分 - 由n分隔,并将 n 递增 1。
int removeDuplicates(vector<int>& nums) {
if(nums.empty())
return 0;
int n = 0;
for(int i = 0; i < nums.size(); ++i){
if(i == 0 || nums[i] != nums[i - 1]){
nums[n++] = nums[i];
}
}
return n;
}
此问题具有线性时间复杂度和恒定空间复杂度(使用此技术解决的问题通常是这种情况)。
排序颜色
给定一个包含 n 个颜色为红色、白色或蓝色的对象的数组,将它们就地排序,以便相同颜色的对象相邻,颜色的顺序为红色、白色和蓝色。在这里,我们将使用整数 0、1 和 2 分别表示红色、白色和蓝色。
注意:您不应该对这个问题使用库的排序功能。
例子:
- 输入:[2,0,2,1,1,0]
- 输出:[0,0,1,1,2,2]
解决方案
这次的组别是:
- 小于 1
- 等于 1
- 大于 1
我们可以用 3 个指针实现什么。
这个实现有点棘手,所以一定要彻底测试它。
void sortColors(vector<int>& nums) {
int smaller = 0, eq = 0, larger = nums.size() - 1;
while(eq <= larger){
if(nums[eq] == 0){
swap(nums[smaller], nums[eq]);
++smaller; ++eq;
} else if (nums[eq] == 2) {
swap(nums[larger], nums[eq]);
--larger;
} else {
eq++;
}
}
}
由于需要遍历数组进行排序,时间复杂度为O(N)。空间复杂度为 O(1)。
出于好奇,这是Dijkstra 描述的荷兰国旗问题的一个实例。
从链表的末尾删除第 n 个节点
给定一个链表和一个数字 n,编写一个函数,返回链表末尾第 n 个节点的值。
解决方案
这是双指针技术最常见的变体之一:引入偏移量,使一个指针达到特定条件,另一个指针位于您感兴趣的位置。
在这种情况下,如果我们将一个指针f移动n 次,然后同时开始将两个指针向前移动一个节点,当f到达列表末尾时,另一个指针s将指向之前的节点我们要删除的节点。
确保您定义了 n = 1 的含义(最后一个元素或最后一个元素之前的元素?),并避免逐一错误。
时间和空间复杂度与前面的问题相同。
2. 指针以不同的速度移动
现在,您将有两个以不同速度移动的指针:在每次迭代中,一个指针将推进一个节点,另一个指针将推进两个节点。我们可以使用这种变体来:
- 获取链表的中间元素
- 检测链表中的循环
- 等等
像往常一样,我会给出一些例子,让它变得容易理解。
找到未知大小的链表的中间节点
给定一个带有头节点 head 的非空单向链表,返回列表的中间节点。如果有两个中间节点,则返回第二个中间节点。
示例 1:
- 输入:[1,2,3,4,5]
- 输出:此列表中的节点 3
解决方案
分两次解决这个问题很容易:第一次我们计算列表的大小L,第二次我们只前进L/2 个节点来找到列表中间的元素。这种方法在时间上具有线性复杂性,在空间上具有恒定性。
我们如何使用 2 个指针在一次通过中找到中间元素?
如果其中一个指针f 的移动速度是另一个s 的两倍,那么当f到达末尾时,s将位于列表的中间。
这是我在 C++ 中的解决方案。确保在测试代码时考虑边缘情况(空列表、奇数和偶数大小的列表等)。
ListNode* middleNode(ListNode* head) {
ListNode* slow = head;
ListNode* fast = head;
while (fast != nullptr && fast->next != nullptr) {
slow = slow->next;
fast = fast->next->next;
}
return slow;
}
检测链表中的循环
给定一个链表,判断它是否有环。为了表示给定链表中的循环,我们使用一个整数 pos 表示链表中尾部连接的位置(0-indexed)。如果 pos 为 -1,则链表中没有环。
示例 1:
- 输入:head = [3,2,0,-4], pos = 1
- 输出:真
- 解释:链表中有一个循环,其尾部连接到第二个节点。
解决方案
最简单的解决方案是将所有节点添加到哈希集。当我们遍历链表时,如果到达一个已经加入集合中的节点,就会有一个循环。如果我们到达列表的末尾,则没有循环。
这具有 O(L) 的时间复杂度,L是列表的长度,空间复杂度为 O(L),因为在最坏的情况下 - 没有循环 - 我们需要将列表的所有元素添加到哈希放。
时间复杂度无法提高。然而,空间复杂度可以降低到 O(1)。想一想如何通过两个以不同速度移动的指针来实现这一点。
让我们称这些指针为fast 和slow。对于每个节点的慢访问,快将向前移动两个节点。为什么?
- 如果 fast 到达列表的末尾,则列表不包含任何循环。
- 如果有一个循环,因为快的移动速度是慢的两倍,所以快节点抓住慢节点只是时间问题(迭代,更准确地说),指向同一个节点,这表明存在一个周期。
现在,让我们将这个解决方案翻译成代码:
bool hasCycle(ListNode *head) {
ListNode* slow = head, *fast = head;
while(fast){
slow = slow->next;
fast = fast->next;
if(!fast)
break;
fast = fast->next;
if(slow == fast)
return true;
}
return false;
}
找到重复的号码
给定一个数组 nums,其中包含 n + 1 个整数,其中每个整数都在 1 和 n 之间(含),证明必须至少存在一个重复数字。假设只有一个重复的数字,找到重复的一个。
示例 1:
- 输入:[1,3,4,2,2]
- 输出:2
解决方案
这是与前面的问题相同的问题/解决方案,用于数组而不是链表。
int findDuplicate(const vector<int>& nums) {
int slow = nums[0], fast = slow;
do {
slow = nums[slow];
fast = nums[nums[fast]];
} while(slow != fast);
slow = nums[0];
while(slow != fast){
slow = nums[slow];
fast = nums[fast];
}
return slow;
}
挑战
以下是使用此技术可以解决的更多问题:
- 检测两个链表是否有共同元素
- 快乐数字
3. 滑动窗口
滑动窗口技术简化了寻找满足特定条件的最佳连续数据块的任务:
- 最长的子阵列…
- 包含…的最短子串
- 等等
您可以将其视为双指针技术的另一种变体,其中根据特定条件分别更新指针。以下是此类问题的基本方法,以伪代码表示:
Create two pointers, l, and r
Create variable to keep track of the result (res)
Iterate until condition A is satisfied:
Based on condition B:
update l, r or both
Update res
Return res
无重复字符的最长子串
给定一个字符串,找出没有重复字符的最长子字符串的长度。
示例 1:
- 输入:“abcabcbb”
- 输出:3
- 解释:答案是“abc”,长度为3
解决方案
在不重复字符的情况下查找最长子字符串的长度听起来很像找到最佳的满足特定条件的连续数据块。
根据我上面描述的配方,您将需要:
- 两个指针l和r,用于定义我们的子字符串s。
- 一个变量sol,用于存储我们目前看到的最长子字符串的长度。
- 一种跟踪形成s的字符的方法:一个集合,seen,将是完美的。
在遍历字符串时:
- 如果当前字符在seen* 中,您必须增加 *l以开始从s的开头删除元素。
- 否则,将角色添加到seen,向前移动r并更新sol。
int lengthOfLongestSubstring(const string& s) {
int sol = 0;
int l = 0, r = 0;
unordered_set<int> seen;
while(r < s.size()) {
const auto find = seen.find(s[r]);
if(find == seen.end()) {
sol = max (sol, r - l + 1);
seen.insert(s[r]);
++r;
} else {
seen.erase(s[l++]);
}
}
return sol;
}
挑战
如需更多练习,您可以尝试以下问题:
- 字符串的排列
- 最大连续数
可能有更简单的解决方案,但专注于使用这种技术来更好地掌握它。
基于递归的技术
4. 动态规划
后面我会写一篇关于这个主题的文章,填补这一部分。
5. 回溯
回溯背后的想法是以聪明的方式探索问题的所有潜在解决方案。它逐步构建候选解决方案,一旦确定候选解决方案不可行,它就会回溯到先前的状态并尝试下一个候选解决方案。
回溯问题为您提供了一系列选择:
- 你应该把这件作品放在这个位置吗?
- 您应该将此号码添加到集合中吗?
- 你接下来应该在这个位置尝试这个数字吗?
- 等等
在您选择了其中一个选项后,它会为您提供一个新的选择列表,直到您达到没有更多选择的状态:要么找到了解决方案,要么没有解决方案。
从视觉上看,您每次选择都从树的根部移动,直到到达一片叶子。回溯算法的基本高级配方(伪代码)如下:
boolean backtracking(Node n){
if(isLeaf(n) {
if(isSolution(candidate)){
sol.add(candidate);
return true;
} else {
return false;
}
}
//Explore all children
for(child in n) {
if(backtracking(child))
return true;
}
return false;
}
这当然可以根据问题而改变:
- 如果您需要所有解决方案,辅助函数将不返回任何内容 (void) 以避免在我们找到第一个解决方案时停止。
- 要回溯,您可能必须先将程序恢复到以前的状态,然后才能继续
- 选择孩子后,需要检测候选解决方案是否可行:可行的定义取决于问题
- 等等
但核心思想是相同的:以系统的方式检查所有路径,并在当前路径不再可行时立即回溯。
N皇后
n-皇后拼图是将 n 个皇后放在 n×n 棋盘上的问题,使得没有两个皇后相互攻击
给定一个整数 n,返回 n-皇后拼图的所有不同解。
每个解决方案都包含 n-queens 布局的独特电路板配置,其中“Q”和“.” 两者分别表示皇后和空位。
例子:
- 输入:4
- 输出: [ [".Q…", // 解决方案 1 “…Q”, “Q…”, “…Q.”],
[" …Q .", // 解决方案 2
“Q…”,
“…Q”,
“.Q…”]
]
- 说明:如上所示,对于 4 皇后拼图,存在两种不同的解决方案。
解决方案
这是一个经典的回溯问题
- 我们需要这里的所有解决方案,这就是我在本节介绍中解释的递归函数不返回任何内容的原因。
- 现在不要太担心isViableSolution函数。试着看看我给你的食谱(略有修改)在行动。
class Solution {
public:
vector<vector<string>> solveNQueens(int n) {
vector<vector<string>> solutions;
/**
This is usually solved with a vector of integers,
where each integer represents the position of the queen in that column.
This particular problem expects strings.
Each string represents a column
*/
vector<string> board(n, string(n, '.'));
solveBoard(solutions, board, 0, n);
return solutions;
}
void solveBoard(vector<vector<string>>& solutions, vector<string>& board, int col, int n){
if(col == n){
solutions.push_back(board);
return;
}
for(int row = 0; row < n; row++){
if(isViableSolution(board, row, col)){
board[row][col] = 'Q';
solveBoard(solutions, board, col + 1, n);
//Backtracking - we bring our board to the previous state
board[row][col] = '.';
}
}
}
bool isViableSolution(vector<string>& board, int row, int col){
int n = board.size();
for(int x = 1; x <= col; x++){
if(board[row][col-x] == 'Q')
return false;
}
for(int x = 1; row - x >= 0 && col >= x; x++){
if(board[row-x][col-x] == 'Q')
return false;
}
for(int x = 1; row + x < n && col >= x; x++){
if(board[row+x][col-x] == 'Q')
return false;
}
return true;
}
};
字母组合
给定一个包含 2-9 数字的字符串,返回该数字可以表示的所有可能的字母组合(检查图表链接)。请注意,1 不映射到任何字母。
例子:
- 输入:“23”
- 输出:[“ad”、“ae”、“af”、“bd”、“be”、“bf”、“cd”、“ce”、“cf”]。
解决方案
对于输入中的每个数字,您都有几个字母可供选择。如果您可以绘制一棵树(这就是我所做的),其中分支是从您所做的不同选择中产生的,那么您很有可能可以应用回溯。
注意:在开始解决任何问题之前,请尝试不同的方法:动态规划、贪心算法、分而治之、算法和数据结构的组合等。编码是最后一步。
我的解决方案,在 C++ 中:
vector<string> letterCombinations(const string &digits) {
if(digits.empty())
return {};
const vector<string> letters {"", "", "abc", "def", "ghi", "jkl", "mno", "pqrs", "tuv", "wxyz"};
vector<string> sol;
string candidate (digits.size(), ' ');
h(sol, 0, candidate, letters, digits);
return sol;
}
void h(vector<string> &sol, int idx, string &candidate, const vector<string> &letters, const string &digits){
if(idx == digits.size()){
sol.push_back(candidate);
return;
}
for(const char &c : letters[digits[idx] - '0']) {
candidate[idx] = c;
h(sol, idx + 1, candidate, letters, digits);
}
}
由于我已经知道解决方案的大小,因此我candidate使用该大小初始化 my并仅修改了位置处的字符idx。如果大小未知,则可以这样做:
string candidate; //instead of string candidate (digits.size(), ' ');
…
for(const char &c : letters[digits[idx] - '0']) {
candidate.push_back(c);
h(sol, idx + 1, candidate, letters, digits);
candidate.pop_back();
}
数独解算器
编写一个程序,通过填充空单元格来解决数独谜题。打开链接以获得更长的描述,包括拼图的图像。
解决方案
在面试中,除非你有足够的时间,否则你不需要实现isViableSolution,只是为了勾勒它。我认识一位朋友,他在现场遇到了这个问题。
尽管代码很长,但主要是因为isViableSolution。否则,它与其他回溯问题没有太大区别。
void solveSudoku(vector<vector<char>>& board){
helper(board);
}
bool helper(vector<vector<char>>& board, int row = 0, int col = 0) {
if(col == size){
col = 0;
++row;
if(row == size){
return true;
}
}
if(board[row][col] 爆肝三万字,带你重温Java基础。。。
❤️ 爆肝三万字《数据仓库体系》轻松拿下字节offer ❤️建议收藏
❤️ 爆肝三万字《数据仓库体系》轻松拿下字节offer ❤️建议收藏
❤️三万字《C/C++面试突击200题》四年面试官爆肝整合❤️(附答案,建议收藏)