我的上一篇关于数据结构和算法的文章:《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] != '.'){
return helper(board, row, col + 1);
}
for(char i = '1'; i <= '9'; ++i){
if(isViableSolution(board, row, col, i)){
board[row][col] = i;
if (helper(board, row, col + 1))
return true;
}
}
board[row][col] = '.';
return false;
}
bool isViableSolution(const vector<vector<char>>& board, int row, int col, char c){
for(const auto& n : board[row]){
if(n == c)
return false;
}
for(int r = 0; r < size; ++r){
if(board[r][col] == c)
return false;
}
const int br = row / nsize;
const int bc = col / nsize;
for(int i = 0; i < nsize ; ++i){
for(int j = 0; j < nsize; ++j){
if(c == board[3 * br + i][3 * bc + j])
return false;
}
}
return true;
}
挑战
- N皇后II
- 生成括号
- 回文分区
- 字母瓷砖的可能性
6.子集
我为以下内容创建了一个通用的单独部分:
- 子集
- 组合
- 排列
- 等等
因为在概念上它们是相似的:获取容器的元素(数组、字符串等)并根据某些规则从中创建子集。
您会注意到其中大部分是回溯问题或非常相似。
子集
给定一组不同的整数 nums,返回所有可能的子集(幂集)。
注意:解决方案集不得包含重复的子集。
例子:
- 输入:nums = [1,2,3]
- 输出: [ [3], [1], [2], [1,2,3], [1,3], [2,3], [1,2], [] ]
解决方案
对于每个索引i,您需要生成两个解决方案:
- 一个包含 a[i]
- 一个不包含 a[i]
直到到达数组的末尾。
这是我们讨论过的内容的简单实现。
vector<vector<int>> subsets(const vector<int>& nums) {
vector<vector<int>> sol;
vector<int> partial;
h(sol, partial, nums);
return sol;
}
void h(vector<vector<int>>& sol, vector<int>& partial, const vector<int>& nums, int idx = 0){
sol.push_back(partial);
if(idx == nums.size()){
return;
}
for(int i = idx; i < nums.size(); ++i){
partial.push_back(nums[i]);
h(sol, partial, nums, i + 1);
partial.pop_back();
}
}
子集 - 包含重复项
这是上一个问题的变体。尝试使用我的代码,看看是否可以更改它以满足新要求。您必须更改少于 5 行。
解决方案
vector<vector<int>> subsetsWithDup(vector<int> nums) {
vector<vector<int>> sol;
vector<int> partial;
sort(nums.begin(), nums.end());
h(sol, partial, nums);
return sol;
}
void h(vector<vector<int>>& sol, vector<int>& partial, const vector<int>& nums, int idx = 0){
sol.push_back(partial);
if(idx == nums.size()){
return;
}
for(int i = idx; i < nums.size(); ++i){
if(i != idx && nums[i] == nums[i - 1])
continue;
partial.push_back(nums[i]);
h(sol, partial, nums, i + 1);
partial.pop_back();
}
}
排列
给定一组不同的整数,返回所有可能的排列。
例子:
- 输入:[1,2,3]
- 输出:[ [1,2,3], [1,3,2], [2,1,3], [2,3,1], [3,1,2], [3,2,1] ]
解决方案
与之前的问题非常相似,但这次我们需要考虑候选数组中的所有元素。我们移动它们的方式是交换不同位置的元素。
vector<vector<int>> permute(const vector<int>& nums) {
vector<vector<int>> sol;
vector<int> p (nums);
h(nums, sol, p);
return sol;
}
void h(const vector<int> &nums, vector<vector<int>> &sol, vector<int> &p, int idx = 0){
if(idx == nums.size()){
sol.push_back(p);
return;
}
for(int i = idx; i < nums.size(); ++i){
swap(p[idx], p[i]);
h(nums, sol, p, idx + 1);
swap(p[idx], p[i]);
}
}
重复排列
给定一组可能包含重复项的数字,返回所有可能的唯一排列。
例子:
- 输入:[1,1,2]
- 输出: [[1,1,2], [1,2,1], [2,1,1] ]
解决方案
我们可以在这里应用与以前相同的技巧进行组合。如果你想不出这个“技巧”,你总是可以使用一个集合来存储解决方案,然后从这个集合中创建和返回一个数组。
void helper(vector<vector<int>>& res, int pos, vector<int>& nums) {
if(pos == nums.size()) {
res.push_back(nums);
return;
}
for(int i = pos; i < nums.size(); ++i) {
if(i != pos && nums[i] == nums[pos])
continue;
swap(nums[i], nums[pos]);
helper(res, pos + 1, nums);
}
rotate(nums.begin() + pos, nums.begin() + pos + 1, nums.end());
}
vector<vector<int>> permuteUnique(vector<int>& nums) {
if(nums.empty())
return {};
sort(nums.begin(), nums.end());
vector<vector<int>> sol;
helper(sol, 0, nums);
return sol;
}
如您所见,无需学习文献中的每一种数据结构和算法来解决大量问题。有价值且可以训练的是系统思考问题的能力和将您的想法转化为代码的技能。
挑战
额外练习:
- 组合和
- 组合和II
- 组合和III
排序和搜索
7. 排序
排序并不是一种解决问题的技术,但正如您在前几节中看到的那样,我们已经能够通过对输入进行排序或假设它已排序然后应用其他技术之一来解决许多问题。
当你面临一个新问题时,问问自己:
- 我可以对输入进行排序吗? 例如,有时您必须返回索引,因此排序不是一种选择
- 如果元素被排序,这个问题会如何改变?
- 排序如何影响整体复杂性?最好的排序算法的复杂度为 O(n log n) - 排序整数可以在 O(n) 中完成
例如,我们的第一个问题(二和)可以用双指针技术在线性时间内解决,因为输入是排序的。但是,如果我们必须排序,则复杂度变为 O(n log n)。
这里有几个问题可以在对输入进行排序后轻松解决。
其他解决方案以时间换取空间,因此在开始编写任何代码之前值得考虑它们。
有效字谜
给定两个字符串 s 和 t,编写一个函数来确定 t 是否是 s 的变位词。
示例 1:
- 输入:s = “anagram”, t = “nagaram”
- 输出:真
示例 2:
- 输入:s = “rat”, t = “car”
- 输出:假
解决方案
根据定义,如果两个字符串以不同的顺序包含相同的字符,则它们是变位词。因此,我们可以简单地对两个字符串进行排序并进行比较。总时间复杂度为 O(n log n)。
bool isAnagram(string s, string t) {
if(s.size() != t.size())
return false;
sort(s.begin(), s.end());
sort(t.begin(), t.end());
return s == t;
}
两个数组的交集
给定两个数组,编写一个函数来计算它们的交集。
示例 1:
- 输入:nums1 = [1,2,2,1],nums2 = [2,2]
- 输出:[2,2]
解决方案
您可以通过对两个数组进行排序并使用基于双指针的方法来解决此问题。在阅读我的解决方案之前尝试一下。
假设我们有以下数组:
- A = [1,2,4,5]
- B = [2,3,5,7,9,11]
还有两个指针,l代表 A,r代表 B,从每个数组的索引 0 开始。
- A[l] = 1
- B[r] = 2
我们需要增加l以查看 A 是否有 2: B 不能在r的右侧包含 1 ,因为它已排序。
简而言之,我们比较两个元素:
- 如果它们相同,我们将它们包含到表示两个数组交集的数组中,并推进两个指针
- 如果它们不同,我们将指针移动到两个元素中最小的一个。
vector<int> intersect(vector<int>& nums1, vector<int>& nums2) {
sort(nums1.begin(), nums1.end());
sort(nums2.begin(), nums2.end());
vector<int> sol;
int i = 0, j = 0;
while( i < nums1.size() && j < nums2.size()) {
if(nums1[i] == nums2[j]) {
sol.push_back(nums1[i]);
++i;
++j;
} else if(nums1[i] > nums2[j]) {
++j;
} else {
++i;
}
}
return sol;
}
这种方法的时间复杂度是 O(n log n) - 即使两个指针部分是线性的 - 而空间复杂度是 O(1) - 当然,不包括存储交集所需的空间,在这种情况下我们可以说它是 O(min(length(A), length(B))。
挑战
- 离原点最近的 K 个点。在另一部分中,我将提出一个不同的解决方案,速度略快。
- 最大数
8. 间隔
我见过的大多数与间隔相关的问题都归结为:
- 将区间建模为两个元素的数组、一对/元组或自定义类(这是最干净的选项)
- 排序输入
- 遍历已排序的数组并根据间隔的开始/结束决定要执行的操作
您可以将其视为在对输入进行排序后可以简化的另一种类型的问题,这就是我将其包含在本节中的原因。
根据我刚刚描述的内容,我将在这里留下我对两个练习的解决方案。在阅读我的解决方案之前尝试它们。
合并间隔
给定一组区间,合并所有重叠的区间。
示例 1:
- 输入:区间 = [[1,3],[2,6],[8,10],[15,18]]
- 输出:[[1,6],[8,10],[15,18]]
- 解释:由于区间 [1,3] 和 [2,6] 重叠,将它们合并为 [1,6]。
解决方案
vector<vector<int>> merge(vector<vector<int>>& intervals) {
sort(intervals.begin(), intervals.end(), [](const auto& i1, const auto& i2){
return i1[0] < i2[0];
});
int i = 0;
vector<vector<int>> sol;
vector<int> curr(2);
while(i < intervals.size()){
curr = intervals[i++];
while(i < intervals.size() && intervals[i][0] <= curr[1]){
curr[1] = max(curr[1], intervals[i][1]);
++i;
}
sol.push_back(curr);
}
return sol;
}
区间列表交点
给定两个闭区间列表,每个区间列表都是成对不相交的,并且按顺序排列。返回这两个区间列表的交集。
解决方案
vector<vector<int>> intervalIntersection(const vector<vector<int>>& A, vector<vector<int>>& B) {
vector<vector<int>> sol;
int i = 0, j = 0;
while(i < A.size() && j < B.size()){
const int lo = max(A[i][0], B[j][0]);
const int hi = min(A[i][1], B[j][1]);
if(lo <= hi){
sol.push_back({lo, hi});
}
if(A[i][1] < B[j][1])
++i;
else
++j;
}
return sol;
}
9. 二分查找的变化
二分搜索是一种搜索算法,可应用于时间复杂度为 O(log n) 和空间复杂度为 O(1) 的排序数据结构(如数组或二叉搜索树)。
您可能不知道的是其实现中最常见的错误。假设我们在范围从 [l,r] 的元素中执行二分搜索,中间的元素通常计算为:
const int m = (l + r) / 2;
你能发现这里的问题吗?
该行可能会溢出。计算这个中间元素的安全方法是:
const int m = l + (r - l) / 2;
这是使用 C++ 模板的二进制搜索的基本实现。如果您了解它的工作原理以及如何正确实现它,您就可以解决许多问题,这些问题只是对需求稍作调整,或者是变相的二分搜索问题。
template<typename T>
int binary_search(const vector<T>& v, int k) {
int l = 0, r = v.size() - 1;
while(l<=r) {
const int m = l + (r - l) / 2;
if(k == v[m]) {
return m;
} else if (k > v[m]) {
l = m + 1;
} else {
r = m - 1;
}
}
return -1;
}
整数平方根
计算并返回 x 的平方根,其中 x 保证为非负整数。
由于返回类型是整数,所以小数位被截断,只返回结果的整数部分。
示例 1:
- 输入:4
- 输出:2
解决方案
我们需要在 [0, x] 范围内找到一个数字,并对其进行排序。这听起来很像一个二分搜索问题。
知道这一点并不重要,但我们可以稍微优化一下:
- 如果x为 0 或 1,则其平方根为x。这让我们从 2 开始测试数字。
- 范围的上限可以减少到 x/2,因为 (x/2)^2 = x^2/4 >= x => x >= 4,这对于范围 [2, …]
有了这个,我们可以在 [2, x/2] 中搜索并加快速度。
int mySqrt(int x) {
if(x == 0 || x == 1)
return x;
int sol = 1;
int l = 2, r = x / 2;
while(l <= r){
const long m = l + (r - l) / 2;
if(m * m == x)
return m;
else if(m * m > x){
r = m - 1;
} else {
sol = m;
l = m + 1;
}
}
return sol;
}
挑战
- 搜索插入位置
- 带重量的随机选择
- 旋转排序数组中的最小值
- 旋转排序数组中的最小值 2
10.广度优先搜索
这是您在探索树和图形时需要了解的技术之一。由于许多问题都可以建模为图形,因此您必须了解这种技术。为了实现它,我们只需要使用一个队列q并将我们从q处理的节点的子节点添加到这个相同的队列中。
在迭代中的任何给定点,BFS 访问距离原点相同距离的所有节点。在其中一些示例之后,这将变得更加清楚。
字梯
给定两个单词(beginWord 和 endWord)和字典的单词列表,找到从 beginWord 到 endWord 的最短转换序列的长度,使得:
- 一次只能更改一个字母。
- 每个转换后的词都必须存在于词表中。
笔记:
- 如果没有这样的转换序列,则返回 0。
- 所有单词的长度相同。
- 所有单词仅包含小写字母字符。
- 您可以假设单词列表中没有重复项。
- 您可以假设 beginWord 和 endWord 非空且不相同。
示例 1:
输入:
- beginWord = “hit”,
- endWord = “cog”,
- wordList = [“hot”,“dot”,“dog”,“lot”,“log”,“cog”]
- 输出:5
- 解释:由于最短的转换是“hit”->“hot”->“dot”->“dog”->“cog”,返回其长度5。
解决方案
亚马逊的现场面试中问过这个问题。这个想法是使用图来模拟这个问题:
- 节点代表单词
- 边连接仅相差一个字母的单词
有了这个设置,这个问题就相当于在图中寻找两个节点之间的路径,BFS 可以解决这个问题。由于所有边都具有相同的权重 (1),因此我们不需要 Dijkstra 或任何其他更高级的算法。
int ladderLength(const string &beginWord, const string &endWord, const vector<string>& wordList) {
if(beginWord == endWord)
return 1;
unordered_set<string> dict(wordList.begin(), wordList.end());
queue<string> todo;
todo.push(beginWord);
dict.erase(beginWord);
int ladder = 1;
while (!todo.empty()) {
ladder++;
int n = todo.size();
for (int i = 0; i < n; i++) {
string word = todo.front();
todo.pop();
for (int j = 0; j < word.size(); j++) {
char c = word[j];
for (int k = 0; k < 26; k++) {
word[j] = 'a' + k;
if (dict.find(word) != dict.end()) {
if (word == endWord) {
return ladder;
}
todo.push(word);
dict.erase(word);
}
}
word[j] = c;
}
}
}
return 0;
}
在您访问 DFS 部分后:
如果我们改用 DFS 会发生什么?你看到任何好处/缺点吗?
订单级树遍历
给定一棵二叉树,返回其节点值的层序遍历。(即从左到右,逐级)。
打开链接以获取问题的图形描述。
解决方案
您只需要在此处对标准 BFS 算法进行一些调整:您需要知道每个级别需要处理队列中的多少元素。
这是一种可以应用于许多其他问题的方法。
vector<vector<int>> levelOrder(TreeNode* root) {
if(!root)
return {};
vector<vector<int>> sol;
queue<TreeNode*> q;
q.push(root);
vector<int> partial;
while(!q.empty()){
int size = q.size();
while(size-->0){
auto n = q.front();
partial.push_back({n->val});
q.pop();
if(n->left)
q.push({n->left});
if(n->right)
q.push({n->right});
}
sol.push_back(partial);
partial.clear();
}
return sol;
}
挑战
让我提出一种不同的挑战:构建一些东西,而不是解决抽象的问题。我觉得它更有趣,你可以将它们添加到你的 Github 个人资料中。这里仅举两个例子:
- 网络爬虫使用 BFS 来探索网站上的所有链接。
- 扫雷舰
11.深度优先搜索
类似于 BFS 的目的:探索树和图。DFS 不保证找到两点之间的最短路径,但它会找到任何现有路径。
它的实现时间通常比 BFS 短。有些人觉得这更容易。其他的,因为递归调用,没有那么多。它是由你决定。如果堆栈的大小开始变大,请确保您考虑潜在的堆栈溢出问题。
有些问题用DFS/递归更容易解决,值得练习。
岛屿数量
给定一个由“1”(陆地)和“0”(水)组成的二维网格图,计算岛屿的数量。岛被水包围,由水平或垂直连接相邻的陆地而形成。您可以假设网格的所有四个边都被水包围。
例子:
- 输入:grid = [ [“1”,“1”,“1”,“1”,“0”], [“1”,“1”,“0”,“1”,“0”], [“1”,“1”,“0”,“0”,“0”], [“0”,“0”,“0”,“0”,“0”]]
- 输出:1
解决方案
一看到矩阵,就想到图。这个问题(及其变体)非常简单:
- 遍历矩阵
- 每找到 1 个,增加计数器并沉没岛屿
要下沉该岛,需要访问矩阵中所有周围的1,这相当于访问了该节点及其所有邻居的所有邻居,这听起来很像一个递归问题。
你也可以尝试使用 BFS 来解决这个问题,但 DFS 要短得多。
int numIslands(vector<vector<char>>& grid) {
int numIslands = 0;
for(int r = 0; r < grid.size(); ++r){
for(int c = 0; c < grid[0].size(); ++c){
if(grid[r][c] == '1'){
++numIslands;
sinkIslands(grid, r, c);
}
}
}
return numIslands;
}
const vector<vector<int>> dirs {{1, 0}, {-1, 0}, {0, 1}, {0, -1}};
void sinkIslands(vector<vector<char>> &m, int r, int c){
m[r][c] = '0';
for(const auto &d : dirs){
const int nr = r + d[0];
const int nc = c + d[1];
if(isValid(m, nr, nc)){
sinkIslands(m, nr, nc);
}
}
}
bool isValid(vector<vector<char>> &m, int r, int c){
return r >= 0 && r < m.size() && c >= 0 && c < m[0].size() && m[r][c] == '1';
}
对称树
给定一棵二叉树,检查它是否是自身的镜像(即围绕其中心对称)。
解决方案
许多与树相关的问题都有相对简单的递归解决方案。这个问题可以使用 BFS 解决,但 DFS 让它变得更容易。
我将把这个留在这里作为你的练习。如果您遇到困难,请使用我的解决方案。
bool isSymmetric(TreeNode* root) {
if(!root)
return true;
return helper(root->left, root->right);
}
bool helper(TreeNode* p, TreeNode* q){
if(!p && !q)
return true;
if(!p || !q)
return false;
return p->val == q->val && iS(p->left, q->right) && iS(p->right, q->left);
}
挑战
接受我为 BFS 提供的相同挑战和练习,并尝试使用 DFS 来实现它们。另外,为了更多练习,请尝试以下练习:
- 路径和
- 路径和 2
- 验证 BST
12.拓扑排序
您可以将此算法视为 DFS 的应用。它的实现只需要对常规 DFS 做一个小改动:处理完一个节点的所有子节点后,将此节点添加到堆栈中。
就是这么简单。
直观理解该算法实现的效果的最佳方法是想象一堆任务,其中一些任务依赖于其他任务(任务 1 在任务 2 完成之前无法开始)。拓扑排序将列出所有这些保留这种依赖关系结构的任务。
让我们用这个算法解决一个问题。
课程表二
你总共需要学习 n 门课程,从 0 到 n-1 标记。有些课程可能有先决条件,例如,要参加课程 0,您必须先参加课程 1,这表示为一对:[0,1]
给定课程总数和先决条件对列表,返回完成所有课程所需的课程顺序。可能有多个正确的订单,您只需要返回其中一个。如果不可能完成所有课程,则返回一个空数组。
示例 1:
- 输入:2, [[1,0]]
- 输出:[0,1]
- 说明:总共有 2 门课程要参加。要参加课程 1,您应该完成课程 0。因此正确的课程顺序是 [0,1]。
示例 2:
- 输入:4, [[1,0],[2,0],[3,1],[3,2]]
- 输出:[0,1,2,3] 或 [0,2,1,3]
- 说明:共有 4 门课程。要参加课程 3,您应该完成课程 1 和课程 2。课程 1 和课程 2 都应该在您完成课程 0之后进行。因此,正确的课程顺序是 [0,1,2,3]。另一个正确的顺序是 [0,2,1,3]。
解决方案
这是经典的拓扑排序问题。有很多课程要参加,有些取决于其他课程。这可以建模为有向图。拓扑排序将返回完成所有课程所需的课程顺序。
应用拓扑排序的先决条件是图是有向无环的。从问题描述中,我们可以看出该图是有向的。我们可以检测它是否包含任何循环并在同一遍中计算拓扑排序。
一种更间接但仍然有效的方法是首先检查它是否有环,只有在没有环的情况下,才计算拓扑排序。
vector<int> findOrder(int numCourses, vector<vector<int>>& prerequisites) {
vector<vector<int>> adj(numCourses, vector<int>(0));
for(const auto &p : prerequisites){
int u = p[0];
int v = p[1];
adj[u].push_back(v);
}
vector<bool> white(numCourses, true),
grey(numCourses),
black(numCourses);
vector<int> sol (0);
for(int i = 0; i < numCourses; ++i){
if(white[i] && hasCycle(adj, i, white, grey, black, sol)){
return {};
}
}
return sol;
}
bool hasCycle(const vector<vector<int>>& adj, int i, vector<bool> &white, vector<bool> &grey, vector<bool> &black, vector<int>& sol){
//We have started exploring this node
white[i] = false;
grey[i] = true;
for(const auto & n : adj[i]){
if(black[i])
continue;
if(grey[n] || hasCycle(adj, n, white, grey, black, sol))
return true;
}
grey[i] = false;
black[i] = true;
sol.push_back(i);
return false;
}
扩展基本数据结构
13.出队
我已经看到数据结构主要用作实现您在本文前面阅读的滑动窗口技术的另一种方法。与标准 FIFO 队列的唯一区别是您可以在队列的两端进行操作(插入和删除元素)。
就是这样。简单的。
让我们看看这样一个微小的改变是如何简化这些问题的。
滑动窗口最大值
给定一个数组 nums,有一个大小为 k 的滑动窗口,它从数组的最左边移动到最右边。您只能在窗口中看到 k 个数字。每次滑动窗口向右移动一个位置。返回最大滑动窗口。
例子:
- 输入:nums = [1,3,-1,-3,5,3,6,7],k = 3
- 输出:[3,3,5,5,6,7]
解决方案
我们将使用出队来存储索引,而不是值。我们需要知道哪些元素仍然是滑动窗口的一部分。对于每次迭代,有四件事要做。
1.移除出列中当前滑动窗口之外的元素(每次迭代一个)
2.删除出列中所有小于我们所在的当前元素的元素,因为它们不能代表该滑动窗口的最大值
3.将当前元素添加到双端队列
4.一旦我们完成了第一个滑动窗口,我们就可以开始向我们的解决方案添加元素。按照设计,我们出队最前面的元素将包含滑动窗口的最大值,这是我们感兴趣的。
此技术可用于查找数组中连续数据块的最小值或其他属性。
vector<int> maxSlidingWindow(const vector<int>& nums, int k) {
vector<int> sol;
deque<int> dq;
for (int i=0; i<nums.size(); i++) {
// Step 1
if (!dq.empty() && dq.front() == i-k)
dq.pop_front();
// Step 2
while (!dq.empty() && nums[dq.back()] < nums[i])
dq.pop_back();
// Step 3
dq.push_back(i);
// Step 4
if (i >= k-1)
sol.push_back(nums[dq.front()]);
}
return sol;
}
挑战
设计自己的循环出队,全面掌握这个数据结构的内部结构。
14.trie
将 trie 视为树的扩展的最佳方式是,您可以在其中存储字符,这些字符在您穿过 trie 的不同分支时形成单词。
在某些变体中,您可以存储后缀而不是前缀,在其中使用压缩来减小特里树的大小等。但从根本上讲,它是另一种类型的树。
它们无处不在:
- 自动完成
- 拼写检查器
- IP路由
- 最长前缀/后缀匹配
- 等等
实现一个trie
使用插入、搜索和startsWith 方法实现一个trie。
解决方案
这是一个简单的 trie 实现(当然是为了面试)。与一棵树相比,您只需:
- 一个额外的布尔值,用于指示该节点是否标记单词的结尾
- 用于存储指向节点子节点的指针的数据结构:哈希表、字符数组等。
class Trie {
private:
struct Node{
bool isWord;
unordered_map<int, Node*> children;
Node() : isWord(false) {}
};
Node* findNode(const string &word){
Node* curr = root;
for(int i = 0; i < word.size() && curr; ++i)
curr = curr->children[word[i] - 'a'];
return curr;
}
public:
Node* root;
/** 在此处初始化您的数据结构。 */
Trie() {
root = new Node();
}
/** 在树中插入一个词。 */
void insert(const string &word) {
Node * curr = root;
for(int i = 0; i < word.size(); ++i){
const char c = word[i] - 'a';
if(!curr->children[c]){
Node* newChild = new Node();
curr->children[c] = newChild;
}
curr = curr->children[c];
}
curr->isWord = true;
}
/** 如果单词在 trie 中,则返回 true。*/
bool search(const string &word) {
Node* curr = findNode(word);
return curr ? curr->isWord : false;
}
/** 如果树中存在以给定前缀开头的任何单词,则返回 true。*/
bool startsWith(const string &prefix) {
Node* curr = findNode(prefix);
return curr ? true : false;
}
};
查词二
给定一个 2D 板和字典中的单词列表,找到板上的所有单词。每个单词必须由相邻单元格的字母构成,其中“相邻”单元格是水平或垂直相邻的单元格。同一个字母单元格不能在一个单词中多次使用。
例子:
- 输入:board = [ [‘o’,‘a’,‘a’,‘n’], [‘e’,‘t’,‘a’,‘e’], [‘i’,‘h’,
‘k’,‘r’], [‘i’,‘f’,‘l’,‘v’] ] words = [“oath”,“pea”,“eat”,“rain”] - 输出:[“吃”,“誓言”]
解决方案
这可能不是解决这个问题的最简单方法,但它是特里树的一个明显应用。
在每一步,你都会构建一些候选字符串,需要检查它是否属于字典。包含字典中所有单词的散列集将提供非常好的性能。那么为什么要打扰一个特里呢?因为 Trie 可以告诉您该路径是否值得探索,从而提高我们解决方案的效率。
在我们之前的例子中,假设我们形成了字符串“oa”。
我们可以检查这个前缀(潜在词)是否存在于我们的树中。它存在是因为我们添加了“誓言”这个词。现在想象我们继续在我们的棋盘上移动并形成字符串“oaa”。
在我们的树中,没有包含前缀“oaa”的单词,因此我们可以在此时回溯。
对于包含所有单词的散列集,您无法进行这种类型的前缀匹配,除非您创建两个不同的表:一个用于前缀,另一个用于单词。
这是一个复杂的问题,因为它结合了不同的元素并且不容易实现,所以如果您需要尝试几次(没有双关语)才能正确解决,请不要气馁。
const vector<vector<int>> dirs {{1, 0}, {-1, 0}, {0, 1}, {0, -1}};
struct Trie{
struct Node{
bool isWord;
unordered_map<char, Node*> children;
Node() : isWord(false){}
};
Node* root;
Trie(){root = new Node();}
void insert (const string& w){
Node* cur = root;
for(const auto &c : w){
if(cur->children.find(c) == cur->children.end()){
cur->children[c] = new Node();
}
cur = cur->children[c];
}
cur->isWord = true;
}
bool hasPrefix(const string &prefix){
Node* cur = root;
for(const auto &c : prefix){
if(cur->children.find(c) == cur->children.end()){
return false;
}
cur = cur->children[c];
}
lastNode = cur;
return cur != nullptr;
}
bool isValidWord(const string &w){
if(lastNode){
bool res = lastNode->isWord;
return res;
}
Node* cur = root;
for(const auto &c : w){
cur = cur->children[c];
if(!cur){
return false;
}
}
lastNode = cur;
return lastNode->isWord;
}
void deleteWord(){
lastNode->isWord = false;
lastNode = nullptr;
}
Node* lastNode;
};
Trie t;
public:
vector<string> findWords(vector<vector<char>>& board, const vector<string>& words) {
for(const auto& w : words)
t.insert(w);
vector<string> sol;
for(int row = 0; row < board.size(); ++row){
for(int col = 0; col < board[0].size(); ++col){
string candidate (1, board[row][col]);
if(t.hasPrefix(candidate))
addWords(board, row, col, sol, candidate);
}
}
return sol;
}
void addWords(vector<vector<char>> &board, int row, int col, vector<string> &sol, string &candidate){
if(t.isValidWord(candidate)){
sol.push_back(candidate);
t.deleteWord();
}
const char old = board[row][col];
board[row][col] = '-';
for(const auto &d : dirs){
const int nrow = row + d[0];
const int ncol = col + d[1];
if(nrow >= 0 && nrow < board.size()
&& ncol >= 0 && ncol < board[0].size()
&& board[nrow][ncol] != '.'
&& t.hasPrefix(candidate + board[nrow][ncol])){
candidate.push_back(board[nrow][ncol]);
addWords(board, nrow, ncol, sol, candidate);
candidate.pop_back();
}
}
board[row][col] = old;
}
挑战
- 设计你自己的自动完成!
15. 同一个数据结构的两个实例
有些问题可以通过使用相同数据结构的两个不同实例来解决,因此当您遇到问题时,请记住这一点。我看到它主要是:
- 队列
- 堆栈
- 数组
不要局限于这些。
从数据流中查找中位数
中位数是有序整数列表中的中间值。如果列表的大小是偶数,则没有中间值。所以中位数是两个中间值的平均值。
例如,
- [2,3,4],中位数为3
- [2,3],中位数为 (2 + 3) / 2 = 2.5
设计一个支持以下两种操作的数据结构:
- void addNum(int num) - 从数据流向数据结构添加一个整数。
- double findMedian() - 返回到目前为止所有元素的中位数。
解决方案
class MedianFinder {
priority_queue<int, vector<int>, less<int>> L;
priority_queue<int, vector<int>, greater<int>> H;
public:
MedianFinder() { }
void addNum(int x) {
if(L.empty() == false && x > L.top()) {
H.emplace(x);
} else {
L.emplace(x);
}
if(H.size() > L.size() + 1) {
L.emplace(H.top());
H.pop();
} else if(L.size() > H.size() + 1) {
H.emplace(L.top());
L.pop();
}
}
double findMedian() {
if(H.size() == L.size()) {
return (H.top() + L.top()) * 0.5;
} else {
return H.size() > L.size() ? H.top() : L.top();
}
}
};
挑战
按难度递增:
- 使用两个栈实现一个队列
- 树之字形顺序遍历
- 来自数据流的媒体
杂项
16.位操作
本节值得单独写一篇文章。在这里,我将列出一些基本技巧和常见的位操作问题。
缺少号码
给定一个包含 n 个不同数字的数组,这些数字取自 0, 1, 2, …, n ,找到数组中缺少的那个。
示例 1:
- 输入:[3,0,1]
- 输出:2
解决方案
这个问题可以通过使用 XOR 运算符来解决:
- 任何与自身异或的位都会产生 0 -> a ^ a = 0
- 任何位与 0 异或产生原始位 -> a ^ 0 = a
- XOR 是关联的 = a ^ (b ^ c) = (a ^ b ) ^ c
如果我们将数组中的所有数字(范围 [0,n] 中的整数)与 [0 到 n] 中的所有整数进行异或,这些对将产生零,并且缺失的数字将与 0 进行异或(结果是自身) ,解决我们的问题。
int missingNumber(const vector<int>& nums) {
int res = nums.size();
for(int i = 0; i < nums.size(); ++i)
res ^= (i ^ nums[i]);
return res;
}
两个人的力量
给定一个整数,编写一个函数来确定它是否是 2 的幂。
解决方案
我在一次采访中得到了这个。
2 的幂可以用二进制表示为前导 1 和一些 0:
- 2:1
- 4:10
- 8:100
- 16:1000
有了这个,很容易弄清楚一个数字是否是 2 的幂。如果您知道以下行的作用,您可以快速实现(我相信您可以自己弄清楚):
x & (x-1)
这个技巧值得了解,因为它被大量使用。
bool isPowerOfTwo(int n) {
return n > 0 ? (n & (n - 1)) == 0 : false;
}
1 的数量
编写一个函数,该函数接受一个无符号整数并返回它具有的“1”位数(也称为汉明权重)。
示例 1:
- 输入:00000000000000000000000000001011
- 输出:3
- 说明:输入的二进制字符串 00000000000000000000000000001011 共有三个“1”位。
解决方案
这个问题非常简单:遍历输入中的所有位并计算它们中有多少是 1。
尝试使用我在上一个练习中向您展示的技巧来提高该算法的性能(在一般情况下)。
int hammingWeight(uint32_t n) {
int sum = 0;
while (n != 0) {
sum++;
n &= (n - 1);
}
return sum;
}
挑战
这些是供您练习之前的技巧:
- 单号
- 格雷码
- 汉明距离
- 4的幂
17. 顶级“K”元素
这是另一种非常常见的问题。您将获得一个元素列表,并且必须返回顶部 K,将顶部定义为:
- 最大/最小
- 最接近/最远的点
- 列表中最常见的
- 等等
面试中可能会遇到以下一些(或某种变体)的问题。
没有单一的数据结构总能提供正确的解决方案,但以下元素非常有用:
- 哈希表
- 优先队列
- 排序(输入或作为中间步骤)
优先级队列通常提供更好的复杂性。
前k个常用词
给定一个非空的单词列表,返回 k 个最频繁的元素。您的答案应按频率从高到低排序。如果两个词具有相同的频率,则首先出现字母顺序较低的词。
示例 1:
- 输入:[“i”, “love”, “leetcode”, “i”, “love”, “coding”], k = 2
- 输出:[“i”,“love”]
- 解释:“i”和“love”是两个最常用的词。请注意,由于字母顺序较低,“i”在“love”之前。
解决方案
非常简单:计算每个单词出现的次数(使用哈希表)并以某种方式返回 k 个最常见的元素。
对于最后一部分,您可以:
将所有具有频率的元素放入一个数组中并对其进行排序
或者
使用优先队列
值得了解第二种方法,因为它可以应用于其他问题。
这是使用优先级队列的 C++ 中的简单解决方案。
vector<string> topKFrequent(const vector<string>& words, int k) {
map<string, int> freq;
for(const auto &w : words)
freq[w]++;
auto l = [](const pair<int, string> & p1, const pair<int, string> &p2){
if(p1.first == p2.first)
return p1.second < p2.second;
else
return p1.first > p2.first;
};
priority_queue<pair<int, string>, vector<pair<int, string>>, decltype(l)> pq(l);
for(auto it = freq.begin(); it != freq.end(); ++it){
if(pq.size() < k){
pq.push({it->second, it->first});
} else {
auto top = pq.top();
if(top.first < it->second){
pq.pop();
pq.push({it->second, it->first});
}
}
}
vector<string> sol (k);
while(!pq.empty()){
sol[--k] = pq.top().second;
pq.pop();
}
return sol;
}
变化
离原点最近的 K 个点
我们有一个飞机上的点列表。找到距离原点 (0, 0) 最近的 K 个点。这里,平面上两点之间的距离是欧几里得距离。
您可以按任何顺序返回答案。答案保证是唯一的(除了它的顺序。)
示例 1:
- 输入:点数 = [[1,3],[-2,2]], K = 1
- 输出:[[-2,2]]
解决方案
显而易见的解决方案是计算到每个点的距离,将它们添加到数组中,排序并获得前 k。这很好,但可以通过优先队列进行改进。
我们使用最大堆,其中堆的根是堆的最大元素。为什么是最大值?我们的堆包含最接近原点的 K 个点,根点指向所有其他点中离原点最远的元素。如果我们找到一个更接近这个点的点,它可能仍然比其他点更远,但是我们需要删除当前的顶部并将这个新点添加到堆中。
typedef pair<int, vector<int>> pivi;
public:
vector<vector<int>> kClosest(const vector<vector<int>>& points, int K) {
auto l = [] (const pivi& p1, const pivi &p2) {
return p1.first < p2.first;
};
priority_queue<pivi, vector<pivi>, decltype(l)> pq(l);
for(const auto &p : points){
//Avoid taking the square root. Won’t change the result and it’s faster
const int d = p[0] * p[0] + p[1] * p[1];
if(pq.size() < K)
pq.push({d, {p[0], p[1]}});
else if(pq.top().first > d){
pq.pop();
pq.push({d, {p[0], p[1]}});
}
}
vector<vector<int>> sol;
for(int i = 1; i <= K; ++i){
sol.push_back(pq.top().second);
pq.pop();
}
return sol;
}
18.K-way合并
这些问题也很常见。所有这些都没有唯一的方法,但是如果您知道如何解决合并 2 个列表的问题,则可以解决将 K 个列表合并为更简单问题的一堆实例的问题。正如我所提到的,这是一种非常强大的方法,您应该始终牢记在心。
合并 2 个排序列表
合并两个排序的链表并将其作为新的排序列表返回。应该通过将前两个列表的节点拼接在一起来制作新列表。
示例:
- 输入:1->2->4, 1->3->4
- 输出:1->1->2->3->4->4
解决方案
我们使用在本文开头看到的相同的双指针技术解决了这个问题。在这种情况下,我们遍历链表而不是数组,但同样的想法也适用。
ListNode* mergeTwoLists(ListNode* l1, ListNode* l2) {
ListNode dh(1), *curr = &dh;
while(l1 && l2){
if(l1->val <= l2->val) {
curr->next = l1;
l1 = l1->next;
} else {
curr->next = l2;
l2 = l2->next;
}
curr = curr->next;
}
if(l1){
curr->next = l1;
} else if(l2) {
curr->next = l2;
}
return dh.next;
}
合并 k 个排序列表
给定一个链表数组,每个链表按升序排序。
将所有链表合并为一个排序链表并返回。
示例 1:
- 输入:lists = [[1,4,5],[1,3,4],[2,6]]
- 输出:[1,1,2,3,4,4,5,6]
- 解释:链接-lists 是: [ 1->4->5, 1->3->4, 2->6 ] 将它们合并为一个排序列表:1->1->2->3->4->4- >5->6
解决方案
这是上一个问题的通用版本。正如我所说,您可以通过一次合并列表 2 将这个问题减少到前一个问题的许多实例。但是,在这里我将提出一个更有效的解决方案。
现在,不是两个已排序的列表,而是 K。我们需要通过选择所有列表中的下一个最小元素来创建一个列表。由于它们已排序,因此它将是这些列表之一的第一个元素。幸运的是,有一个数据结构在 O(1) 中返回其最小元素并具有 O(log K) 的插入复杂度:优先队列。
对于每个列表,我们向优先级队列添加一对包含:
- 那个名单的头
- 要记住该元素属于哪个列表的索引
在我们弹出一个元素后,我们将它的值添加到我们的解决方案中,并将该列表的新头添加到优先级队列(该对存储列表的值和索引)。
有了这些信息,尝试解决问题。与不先尝试阅读我的解决方案相比,您将从中学到更多。
ListNode* mergeKLists(vector<ListNode*>& lists) {
typedef pair<ListNode*, int> pni;
auto comp = [](const pni & p1, const pni& p2) {
return p1.first->val > p2.first->val;
};
priority_queue<pni, vector<pni>, decltype(comp)> pq (comp);
for(int i = 0; i < lists.size(); ++i){
if(lists[i]){
pq.push({lists[i], i});
lists[i] = lists[i]->next;
}
}
ListNode dh(-1), *curr = &dh;
while(!pq.empty()){
const auto p = pq.top(); pq.pop();
curr->next = p.first;
curr = curr->next;
ListNode* next = lists[p.second];
if(next){
int idx = p.second;
pq.push({next, idx});
lists[p.second] = lists[p.second]->next;
}
}
return dh.next;
}
19.滚动哈希
Rabin Karp是一个很好的例子,它展示了如何巧妙地使用滚动散列函数来设计简单而高效的算法。它可用于在文本t 中查找字符串s。
您需要记住的基本思想才能重建此算法:
- 这是对蛮力方法的改进,在蛮力方法中,您比较s和每个候选子串c,这是低效的。
- 如果两个字符串相同,它们将产生相同的哈希值。
- 反之则不成立:两个不同的字符串可能产生相同的哈希值。
- 使用滚动散列函数,我们可以在 O(1) 中计算新字符串的散列
如果我们将所有这些放在一起,我们将有效地计算哈希值,并且仅在c和s具有相同的哈希值时才比较它们,从而减少比较次数,从而降低我们算法的平均复杂度(最坏的情况,我们需要比较所有子串,并且回到蛮力方法)。
在文本中查找字符串
返回针头在干草堆中第一次出现的索引,如果针头不是干草堆的一部分,则返回 -1。
示例 1:
- 输入:haystack = “你好”,needle = “ll”
- 输出:2
示例 2:
- 输入:haystack = “aaaaa”,needle = “bba”
- 输出:-1
解决方案
基于以上段落,尝试自己编写Rabin-Karp算法来解决这个问题。
int strStr(const string& haystack, const string& needle) {
int nsize = needle.size();
if(nsize == 0)
return 0;
int hsize = haystack.size();
if(nsize > hsize)
return -1;
int msbPower = 1;
int nhash = 0;
int hhash = 0;
const int mod = 10000009; //A big prime number
for(int i=0; i<nsize; i++) {
if(i != 0) {
msbPower *= 26;
msbPower %= mod;
}
nhash = nhash*26+needle[i];
nhash %= mod;
hhash = hhash*26+haystack[i];
hhash %= mod;
}
if(nhash == hhash && haystack.compare(0, nsize, needle) == 0)
return 0;
for(int i=1; i<=hsize-nsize; i++) {
hhash -= haystack[i-1]*msbPower;
hhash %= mod;
if(hhash < 0)
hhash += mod;
hhash = hhash*26+haystack[i+nsize-1];
hhash %= mod;
if(nhash == hhash && haystack.compare(i, nsize, needle) == 0)
return i;
}
return -1;
}
20.如何学习新的算法和数据结构
正如我在这里和其他文章中已经提到的,不要试图记住事情。它适得其反。您最终会混淆概念,为错误的问题提供正确的解决方案,并忘记您记住的内容。这就是您需要了解算法的原因。如果您了解基本的数据结构并且可以将您的想法变成代码,那么您也可以编写这些算法。
学习它们的最好方法是我在描述 Rabin-Karp 算法时向你展示的:
1.了解这个算法或数据结构试图解决什么问题:在这里,了解其他替代方案的不足之处是有帮助的。
2. 将其分解为定义算法的关键元素或步骤。分别了解它们以及它们如何连接。
由此,对算法进行编码。
示例 1:二分查找
1.二分搜索是对排序数组的线性搜索的改进。它在每一步都将数组的大小减少了一半,实现了 O(logN) 而不是 N 的复杂度。
2.两点:a)由于数组已排序,将我们的目标与数组的中间元素进行比较让我们在每一步都丢弃数组的一半(其中一半包含与目标相比太大或太小的元素。b)如果指针交叉,这是l > r,没有解决方案。3.
template<typename T>
int binary_search(const vector<T>& v, int k) {
int l = 0, r = v.size() - 1;
while(l<=r) {
const int m = l + (r - l) / 2;
if(k == v[m]) {
return m;
} else if (k > v[m]) {
l = m + 1;
} else {
r = m - 1;
}
}
return -1;
}
示例 2:四叉树
我想向您展示如何将此分解过程应用于更复杂的数据结构。在面试中你不太可能需要了解四叉树。我只读过一次,发现它们非常有趣,因此我决定实施它们。
什么是四叉树?
来自维基百科:
四叉树是一种树数据结构,其中每个内部节点恰好有四个子节点。四叉树是八叉树的二维模拟,最常用于通过将二维空间递归细分为四个象限或区域来划分二维空间…
它试图解决什么问题?
现在我们知道了正式的定义,我认为从它的一个应用程序中可以更容易地直观地了解四叉树可以做什么。
想象一下,我们正试图模拟一个由一堆粒子组成的物理系统,每个粒子都有一定的质量。每个粒子都会吸引系统中的每个其他粒子。如果我们有 N 个粒子,在我们模拟的每一步,我们需要处理的交互数量随着 N^2 增长。对于较大的 N 值,这将非常慢。
由于两个粒子之间的吸引力随着距离的平方而衰减,我们可以忽略远处粒子的贡献。我们如何在不遍历所有粒子的情况下知道哪些粒子离得很远(我们首先试图避免的问题)?这是四叉树派上用场的地方。
使用四叉树,我们可以高效地查询某个区域内的所有粒子。查找需要 O(logN) 时间,使我们每一步的总时间为 O(n log n),因为我们必须对 N 个粒子执行此操作。
请注意,此时我们不太关心实现。这更像是对数据结构的高级理解,并了解它的用处。
在下一节中,我们将详细介绍。
四叉树的关键要素是什么?
让我们回到定义:
四叉树是一种树数据结构,其中每个内部节点恰好有四个子节点。
- 他们将空间分解为适应性强的细胞
- 每个单元格(或桶)都有一个最大容量。当达到最大容量时,桶分裂
- 树目录遵循四叉树的空间分解。
因此,我们的四叉树类与您可能遇到的任何其他树(包括我之前介绍的树)并没有太大不同。每个节点将包含:
- 表示其最大容量的整数
- 一个布尔值,指示它是否是叶节点(这意味着节点已被划分,因为它达到了最大容量)
- 指向它的指针 4 个孩子
- 包含在该节点中的点的数组
了解数据结构试图解决什么问题并将其分解为基本元素可以更容易地实现它,尤其是记住数据结构是关于什么以及何时可以使用它。您只需要“记住”它的定义和属性——如果您考虑它要解决的问题,就会更容易记住。
把这一切放在一起
您可以尝试用您喜欢的语言在这里实现它。
class Solution {
private:
Node *constructHelper(vector<vector<int>> &grid, int len, int x, int y) {
int flag = grid[x][y];
for (int i = x; i < x + len; i++)
for (int j = y; j < y + len; j++)
//if not a leaf, go deeper
if (flag != grid[i][j])
{
Node *tl = constructHelper(grid, len / 2, x, y);
Node *tr = constructHelper(grid, len / 2, x, y + len / 2);
Node *bl = constructHelper(grid, len / 2, x + len / 2, y);
Node *br = constructHelper(grid, len / 2, x + len / 2, y + len / 2);
return new Node(true, false, tl, tr, bl, br);
}
return new Node(grid[x][y] == 1, true, nullptr, nullptr, nullptr, nullptr);
}
public:
Node *construct(vector<vector<int>> &grid) {
int n = grid.size();
return (n == 0) ? nullptr : constructHelper(grid, n, 0, 0);
}
};
很高兴你能看到这里
这里列出了一些我在文章中没有明确提及的值得了解的其他主题。您不需要一口气学完所有内容。
- 分离集。非常方便地解决许多问题,并且易于使用数组或树状结构来实现。
- 最短路径算法。Dijktra 的算法,至少知道有一个“启发式优化版本” A*。还有Bellman-Ford和Floyd-Warshall。
- 最小生成树。它们是什么以及如何计算它们。这两个是最常见的算法:Kruskal(本质上,排序边和计算连接组件,可以使用不相交集轻松完成)和Prim(非常类似于 Dijkstra 的最短路径算法)。
- 平衡树。种类很多,但是知道红黑树就足够了。
总结
我已经列出了 20 种技巧,将对您的学习、面试、工作有所帮助。其实我们的重点不是要学习书中的每一个数据结构或算法。如您所见,它们中的大多数要么是基本技术,要么是对您已经知道的基本数据结构的小调整。我想告诉你,你可以用你已经知道的东西拓展延伸学到更多东西。
我已经写了很长一段时间的技术博客,并且主要通过CSDN发表,这是我的一篇技术文章/教程。希望你们会喜欢!
关注作者公众号【海拥】回复【进群】,免费下载CSDN资源和百度文库资源
如果你真的从这篇文章中学到了一些新东西,喜欢它,收藏它并与你的小伙伴分享。🤗最后,不要忘了❤或📑支持一下哦