一,数组简介
1,集合,列表和数组
1)集合:
集合一般被定义为:由一个或多个确定的元素所构成的整体。
通俗来讲,集合就是将一组事物组合在一起。
集合有什么特性呢?
首先,集合里的元素类型不一定相同。 你可以将商品看作一个集合,也可以将整个商店看作一个集合,这个商店中有人或者其他物品也没有关系。
其次,集合里的元素没有顺序。 我们不会这样讲:我想要集合中的第三个元素,因为集合是没有顺序的。
事实上,这样的集合并不直接存在于编程语言中。然而,实际编程语言中的很多数据结构,就是在集合的基础上添加了一些规则形成的。
2)列表:
列表(又称线性列表)的定义为:是一种数据项构成的有限序列,即按照一定的线性顺序,排列而成的数据项的集合。
列表的概念是在集合的特征上形成的,它具有顺序,且长度是可变的。你可以把它看作一张购物清单:
在这张清单中:
购物清单中的条目代表的类型可能不同,但是按照一定顺序进行了排列;
购物清单的长度是可变的,你可以向购物清单中增加、删除条目。
在编程语言中,列表最常见的表现形式有数组和链表,而我们熟悉的栈和队列则是两种特殊类型的列表。除此之外,向列表中添加、删除元素的具体实现方式会根据编程语言的不同而有所区分。
3)数组:
数组是列表的实现方式之一,也是面试中经常涉及到的数据结构。
正如前面提到的,数组是列表的实现方式,它具有列表的特征,同时也具有自己的一些特征。然而,在具体的编程语言中,数组这个数据结构的实现方式具有一定差别。比如 C++ 和 Java 中,数组中的元素类型必须保持一致,而 Python 中则可以不同。Python 中的数组叫做 list,具有更多的高级功能。
那么如何从宏观上区分列表和数组呢?这里有一个重要的概念:索引。
首先,数组会用一些名为 索引 的数字来标识每项数据在数组中的位置,且在大多数编程语言中,索引是从 0 算起的。我们可以根据数组中的索引,快速访问数组中的元素。
而列表中没有索引,这是数组与列表最大的不同点。
其次,数组中的元素在内存中是连续存储的,且每个元素占用相同大小的内存。要理解这一点,我们需要了解数组在内存中的存储方式
相反,列表中的元素在内存中可能彼此相邻,也可能不相邻。比如列表的另一种实现方式——链表,它的元素在内存中则不一定是连续的。
二,二维数组
准确的来说,数组可以有多维,但是最常见的就是二维了。
前面的章节中,我们已经了解了 一维数组。然而,有时候,我们可能需要用到 多维数组,它更适合像表或矩阵这样更复杂的结构。
本章节中,我们将重点围绕二维数组来解释:
二维数组在内存中是如何存放的?
如何运用二维数组来解决问题?
例题1:
旋转矩阵:
面试题 01.07. 旋转矩阵 - 力扣(LeetCode)
https://leetcode.cn/problems/rotate-matrix-lcci/
旋转矩阵 - 旋转矩阵 - 力扣(LeetCode)
1,使用辅助数组
对于矩阵中的第三行和第四行同理。这样我们可以得到规律:
对于矩阵中第i行的第j个元素,在旋转后,它出现在倒数第i列的第j个位置。
我们将其翻译成代码。由于矩阵中的行列从0开始计数,因此对于矩阵中的元素matrix[rou][col,在旋转后,它的新位置为matrinew[co][n - row - 1]。
这样一来,我们使用一个与matrix大小相同的辅助数组matrix new,临时存储旋转后的结果。我们遍历matrix中的每-个元愫,根据_上述规则将该元素存放到matrix new中对应的位置。在遍历完成之后,再将matrix new中的结果复制到原数组中即可。
class Solution { public: void rotate(vector<vector<int>>& matrix) { int n = matrix.size(); // C++ 这里的 = 拷贝是值拷贝,会得到一个新的数组 auto matrix_new = matrix; for (int i = 0; i < n; ++i) { for (int j = 0; j < n; ++j) { matrix_new[j][n - i - 1] = matrix[i][j]; } } // 这里也是值拷贝 matrix = matrix_new; } };
复杂度分析
●时间复杂度: O(N2),中N是matrix的边长。
●空间复杂度: O(N2)。我们需要使用一个和matrix大小相同的辅助数组。
2,原地旋转
class Solution { public: void rotate(vector<vector<int>>& matrix) { int n = matrix.size(); for (int i = 0; i < n / 2; ++i) { for (int j = 0; j < (n + 1) / 2; ++j) { int temp = matrix[i][j]; matrix[i][j] = matrix[n - j - 1][i]; matrix[n - j - 1][i] = matrix[n - i - 1][n - j - 1]; matrix[n - i - 1][n - j - 1] = matrix[j][n - i - 1]; matrix[j][n - i - 1] = temp; } } } };
复杂度分析
●时间复杂度: O(N2), 中N是matrix的边长。我们需要枚举的子矩阵大小为O([n/2」x(n+ 1)/2]) = O(N2)。
●空间复杂度: O(1)。 为原地旋转。
3,用翻转代替旋转:
class Solution { public: void rotate(vector<vector<int>>& matrix) { int n = matrix.size(); // 水平翻转 for (int i = 0; i < n / 2; ++i) { for (int j = 0; j < n; ++j) { swap(matrix[i][j], matrix[n - i - 1][j]); } } // 主对角线翻转 for (int i = 0; i < n; ++i) { for (int j = 0; j < i; ++j) { swap(matrix[i][j], matrix[j][i]); } } } };
复杂度分析
时间复杂度:O(N^2),其中 NN 是 matrix 的边长。对于每一次翻转操作,我们都需要枚举矩阵中一半的元素。
空间复杂度:O(1)。为原地翻转得到的原地旋转。
例题2:零矩阵
面试题 01.08. 零矩阵 - 力扣(LeetCode)
https://leetcode.cn/problems/zero-matrix-lcci/
1,使用标记数组:
思路和算法
我们可以用两个标记数组分别记录每一行和每一列是否有零出现。
具体地,我们首先遍历该数组一次,如果某个元素为 0,那么就将该元素所在的行和列所对应标记数组的位置置为 true。最后我们再次遍历该数组,用标记数组更新原数组即可。
class Solution { public: void setZeroes(vector<vector<int>>& matrix) { int m = matrix.size(); int n = matrix[0].size(); vector<int> row(m), col(n); for (int i = 0; i < m; i++) { for (int j = 0; j < n; j++) { if (!matrix[i][j]) { row[i] = col[j] = true; } } } for (int i = 0; i < m; i++) { for (int j = 0; j < n; j++) { if (row[i] || col[j]) { matrix[i][j] = 0; } } } } };
复杂度分析
时间复杂度:O(mn),其中 m 是矩阵的行数,n 是矩阵的列数。我们至多只需要遍历该矩阵两次。
空间复杂度:O(m+n),其中 m 是矩阵的行数,n 是矩阵的列数。我们需要分别记录每一行或每一列是否有零出现。
2,使用两个标记变量
思路和算法
我们可以用矩阵的第一行和第一列代替方法一中的两个标记数组,以达到 O(1) 的额外空间。但这样会导致原数组的第一行和第一列被修改,无法记录它们是否原本包含 0。因此我们需要额外使用两个标记变量分别记录第一行和第一列是否原本包含 0。
在实际代码中,我们首先预处理出两个标记变量,接着使用其他行与列去处理第一行与第一列,然后反过来使用第一行与第一列去更新其他行与列,最后使用两个标记变量更新第一行与第一列即可。
class Solution { public: void setZeroes(vector<vector<int>>& matrix) { int m = matrix.size(); int n = matrix[0].size(); int flag_col0 = false, flag_row0 = false; for (int i = 0; i < m; i++) { if (!matrix[i][0]) { flag_col0 = true; } } for (int j = 0; j < n; j++) { if (!matrix[0][j]) { flag_row0 = true; } } for (int i = 1; i < m; i++) { for (int j = 1; j < n; j++) { if (!matrix[i][j]) { matrix[i][0] = matrix[0][j] = 0; } } } for (int i = 1; i < m; i++) { for (int j = 1; j < n; j++) { if (!matrix[i][0] || !matrix[0][j]) { matrix[i][j] = 0; } } } if (flag_col0) { for (int i = 0; i < m; i++) { matrix[i][0] = 0; } } if (flag_row0) { for (int j = 0; j < n; j++) { matrix[0][j] = 0; } } } };
复杂度分析
时间复杂度:O(mn),其中 m 是矩阵的行数,n 是矩阵的列数。我们至多只需要遍历该矩阵两次。
空间复杂度:O(1)。我们只需要常数空间存储若干变量。
3,使用一个标记变量
思路和算法
我们可以对方法二进一步优化,只使用一个标记变量记录第一列是否原本存在0。这样,第一列的第一个元素即可以标记第一行是否出现 0。但为了防止每一列的第一个元素被提前更新,我们需要从最后一行开始,倒序地处理矩阵元素。
class Solution { public: void setZeroes(vector<vector<int>>& matrix) { int m = matrix.size(); int n = matrix[0].size(); int flag_col0 = false; for (int i = 0; i < m; i++) { if (!matrix[i][0]) { flag_col0 = true; } for (int j = 1; j < n; j++) { if (!matrix[i][j]) { matrix[i][0] = matrix[0][j] = 0; } } } for (int i = m - 1; i >= 0; i--) { for (int j = 1; j < n; j++) { if (!matrix[i][0] || !matrix[0][j]) { matrix[i][j] = 0; } } if (flag_col0) { matrix[i][0] = 0; } } } };
复杂度分析
时间复杂度:O(mn),其中 m 是矩阵的行数,n 是矩阵的列数。我们至多只需要遍历该矩阵两次。
空间复杂度:O(1)。我们只需要常数空间存储若干变量。
例题3:对角线遍历
498. 对角线遍历 - 力扣(LeetCode)
https://leetcode.cn/problems/diagonal-traverse/
1,直接模拟:
思路与算法
根据题目要求,矩阵按照对角线进行遍历。设矩阵的行数为m,矩阵的列数为n,我们仔细观察对角线
遍历的规律可以得到如下信息:
●一共有m+n- 1对角线,相邻的对角线的遍历方向不同,当前遍历方向为从左下到右上,则
紧挨着的下一条对角线遍历方向为从右上到左下;
●设对角线从上到下的编号为i∈[0,m+n- 2]:
。当i为偶数时,则第i条对角线的走向是从下往上遍历;
。当i为猗数时,则第i条对角线的走向是从上往下遍历;
●当第i条对角线从下往上遍历时,每次行索引减1,列索引加1, 直到矩阵的边缘为止:
。当i< m时,则此时对角线遍历的起点位置为(i, 0);
。当i≥m时,则此时对角线遍历的起点位置为(m- 1,i- m+ 1);
●当第i条对角线从上往下遍历时,每次行索引加1,列索引减1,直到矩阵的边缘为止:
。当i< n时,则此时对角线遍历的起点位置为(0,i);
。当i≥n时,则此时对角线遍历的起点位置为(i-n+ 1,n- 1);
根据以上观察得出的结论,我们直接模拟遍历所有的对角线即可。
class Solution { public: vector<int> findDiagonalOrder(vector<vector<int>>& mat) { int m = mat.size(); int n = mat[0].size(); vector<int> res; for (int i = 0; i < m + n - 1; i++) { if (i % 2) { int x = i < n ? 0 : i - n + 1; int y = i < n ? i : n - 1; while (x < m && y >= 0) { res.emplace_back(mat[x][y]); x++; y--; } } else { int x = i < m ? i : m - 1; int y = i < m ? 0 : i - m + 1; while (x >= 0 && y < n) { res.emplace_back(mat[x][y]); x--; y++; } } } return res; } };
复杂度分析
时间复杂度:O(m×n),其中 m 为矩阵行的数量,n 为矩阵列的数量。需要遍历一遍矩阵中的所有元素,需要的时间复杂度为 O(m×n)。
空间复杂度:O(1)。除返回值外不需要额外的空间。
三,字符串简介
正如我们在概述中提到的那样,字符串是一个由字符构成的数组。
本章节中,我们将深入研究字符串。完成本章后,你将:
熟悉字符串中的 基本操作,尤其是在数组中没有的独特操作;
理解不同 比较 函数之间的区别;
理解字符串 是否可变 以及导致连接过程中出现的问题;
能够解决与字符串相关的基本问题,如排序、子串、字符串匹配等。
例题1:最长公共前缀
14. 最长公共前缀 - 力扣(LeetCode)
https://leetcode.cn/problems/longest-common-prefix/
1,横向扫描:
用LCP(S1... Sn)表示字符串S1... Sn的最长公共前缀。
可以得到以下结论:
LCP(S1...Sn) = LCP(LCP(LCP(S1, S2), S),..n
基于该结论,可以得到一种查找字符串数组中的最长公共前缀的简单方法。依次遍历字符串数组中的每个字符串,对于每个遍历到的字符串,更新最长公共前缀,当遍历完所有的字符串以后,即可得到字符串数组中的最长公共前缀。
如果在尚未遍历完所有的字符串时,最长公共前缀已经是空串,则最长公共前缀一定是空串,因此不需要继续遍历剩下的字符串,直接返回空串即可。
class Solution { public: string longestCommonPrefix(vector<string>& strs) { if (!strs.size()) { return ""; } string prefix = strs[0]; int count = strs.size(); for (int i = 1; i < count; ++i) { prefix = longestCommonPrefix(prefix, strs[i]); if (!prefix.size()) { break; } } return prefix; } string longestCommonPrefix(const string& str1, const string& str2) { int length = min(str1.size(), str2.size()); int index = 0; while (index < length && str1[index] == str2[index]) { ++index; } return str1.substr(0, index); } };
复杂度分析
时间复杂度:O(mn),其中 m 是字符串数组中的字符串的平均长度,n 是字符串的数量。最坏情况下,字符串数组中的每个字符串的每个字符都会被比较一次。
空间复杂度:O(1)。使用的额外空间复杂度为常数。
2,纵向扫描:
方法一是横向扫描,依次遍历每个字符串,更新最长公共前缀。另一种方法是纵向扫描。纵向扫描时,从前往后遍历所有字符串的每一列,比较相同列上的字符是否相同,如果相同则继续对下一列进行比较,如果不相同则当前列不再属于公共前缀,当前列之前的部分为最长公共前缀。
class Solution { public: string longestCommonPrefix(vector<string>& strs) { if (!strs.size()) { return ""; } int length = strs[0].size(); int count = strs.size(); for (int i = 0; i < length; ++i) { char c = strs[0][i]; for (int j = 1; j < count; ++j) { if (i == strs[j].size() || strs[j][i] != c) { return strs[0].substr(0, i); } } } return strs[0]; } };
复杂度分析
时间复杂度:O(mn),其中 m 是字符串数组中的字符串的平均长度,n 是字符串的数量。最坏情况下,字符串数组中的每个字符串的每个字符都会被比较一次。
空间复杂度:O(1)。使用的额外空间复杂度为常数。
3,分治:
注意到LCP的计算满足结合律,有以下结论:
LCP(S1...Sn)= LCP(LCP(S1... Sk), LCP(Sk+1... Sn)) .
其中LCP(S1...Sn)是字符串S... Sn的最长公共前缀,1 < k< n。
基于上述结论,可以使用分治法得到字符串数组中的最长公共前缀。对于问题LCP(S-S;),可以分解成两个子问题LCP(Si...Smid)与LCP(Smid+1...S;),中mid= i+j/2。对两个子问题分别求解,然后对两个子问题的解计算最长公共前缀,即为原问题的解。
class Solution { public: string longestCommonPrefix(vector<string>& strs) { if (!strs.size()) { return ""; } else { return longestCommonPrefix(strs, 0, strs.size() - 1); } } string longestCommonPrefix(const vector<string>& strs, int start, int end) { if (start == end) { return strs[start]; } else { int mid = (start + end) / 2; string lcpLeft = longestCommonPrefix(strs, start, mid); string lcpRight = longestCommonPrefix(strs, mid + 1, end); return commonPrefix(lcpLeft, lcpRight); } } string commonPrefix(const string& lcpLeft, const string& lcpRight) { int minLength = min(lcpLeft.size(), lcpRight.size()); for (int i = 0; i < minLength; ++i) { if (lcpLeft[i] != lcpRight[i]) { return lcpLeft.substr(0, i); } } return lcpLeft.substr(0, minLength); } };
复杂度分析
●时间复杂度: O(mn),中m是字符串数组中的字符串的平均长度,n是字符串的数量。时间复杂度的递推式是T(n)=2*T(n/2)+ O(m),通过计算可得T(n) = O(mn)。
●空间复杂度: O(mlogn), 中m是字符串数组中的字符串的平均长度, n是字符串的数量。空间复杂度主要取决于递归调用的层数,层数最大为logn,每层需要m的空间存储返回结果。
4,二分查找
显然,最长公共前缀的长度不会超过字符串数组中的最短字符串的长度。用minLength表示字符串数组中的最短字符串的长度,则可以在[0, minLength]的范围内通过二分查找得到最长公共前缀的长度。每次取查找范围的中间值mid,判断每个字符串的长度为mid的前缀是否相同,如果相同则最长公共前缀的长度一定大于或等 于mid,如果不相同则最长公共前缀的长度-定于mid,通过上述方式将查找范围缩小-半,直到得到最长公共前缀的长度。
class Solution { public: string longestCommonPrefix(vector<string>& strs) { if (!strs.size()) { return ""; } int minLength = min_element(strs.begin(), strs.end(), [](const string& s, const string& t) {return s.size() < t.size();})->size(); int low = 0, high = minLength; while (low < high) { int mid = (high - low + 1) / 2 + low; if (isCommonPrefix(strs, mid)) { low = mid; } else { high = mid - 1; } } return strs[0].substr(0, low); } bool isCommonPrefix(const vector<string>& strs, int length) { string str0 = strs[0].substr(0, length); int count = strs.size(); for (int i = 1; i < count; ++i) { string str = strs[i]; for (int j = 0; j < length; ++j) { if (str0[j] != str[j]) { return false; } } } return true; } };
复杂度分析
时间复杂度:O(mnlogm),其中 m 是字符串数组中的字符串的最小长度,n 是字符串的数量。二分查找的迭代执行次数是 O(logm),每次迭代最多需要比较mn 个字符,因此总时间复杂度是 O(mnlogm)。
空间复杂度:O(1)。使用的额外空间复杂度为常数。
例题2:最长回文子串
5. 最长回文子串 - 力扣(LeetCode)
https://leetcode.cn/problems/longest-palindromic-substring/
#include <iostream> #include <string> #include <vector> using namespace std; class Solution { public: string longestPalindrome(string s) { int n = s.size(); if (n < 2) { return s; } int maxLen = 1; int begin = 0; // dp[i][j] 表示 s[i..j] 是否是回文串 vector<vector<int>> dp(n, vector<int>(n)); // 初始化:所有长度为 1 的子串都是回文串 for (int i = 0; i < n; i++) { dp[i][i] = true; } // 递推开始 // 先枚举子串长度 for (int L = 2; L <= n; L++) { // 枚举左边界,左边界的上限设置可以宽松一些 for (int i = 0; i < n; i++) { // 由 L 和 i 可以确定右边界,即 j - i + 1 = L 得 int j = L + i - 1; // 如果右边界越界,就可以退出当前循环 if (j >= n) { break; } if (s[i] != s[j]) { dp[i][j] = false; } else { if (j - i < 3) { dp[i][j] = true; } else { dp[i][j] = dp[i + 1][j - 1]; } } // 只要 dp[i][L] == true 成立,就表示子串 s[i..L] 是回文,此时记录回文长度和起始位置 if (dp[i][j] && j - i + 1 > maxLen) { maxLen = j - i + 1; begin = i; } } } return s.substr(begin, maxLen); } };
例题3:反转字符串中的单词
151. 反转字符串中的单词 - 力扣(LeetCode)
https://leetcode.cn/problems/reverse-words-in-a-string/
class Solution { public: void reverse(string& s, int start, int end){ //翻转,区间写法:左闭又闭 [] for (int i = start, j = end; i < j; i++, j--) { swap(s[i], s[j]); } } void removeExtraSpaces(string& s) {//去除所有空格并在相邻单词之间添加空格, 快慢指针。 int slow = 0; //整体思想参考https://programmercarl.com/0027.移除元素.html for (int i = 0; i < s.size(); ++i) { // if (s[i] != ' ') { //遇到非空格就处理,即删除所有空格。 if (slow != 0) s[slow++] = ' '; //手动控制空格,给单词之间添加空格。slow != 0说明不是第一个单词,需要在单词前添加空格。 while (i < s.size() && s[i] != ' ') { //补上该单词,遇到空格说明单词结束。 s[slow++] = s[i++]; } } } s.resize(slow); //slow的大小即为去除多余空格后的大小。 } string reverseWords(string s) { removeExtraSpaces(s); //去除多余空格,保证单词之间之只有一个空格,且字符串首尾没空格。 reverse(s, 0, s.size() - 1); int start = 0; //removeExtraSpaces后保证第一个单词的开始下标一定是0。 for (int i = 0; i <= s.size(); ++i) { if (i == s.size() || s[i] == ' ') { //到达空格或者串尾,说明一个单词结束。进行翻转。 reverse(s, start, i - 1); //翻转,注意是左闭右闭 []的翻转。 start = i + 1; //更新下一个单词的开始下标start } } return s; } };
2,双端队列
由于双端队列支持从队列头部插入的方法,因此我们可以沿着字符串一个一个单词处理,然后将单词压入队列的头部,再将队列转成字符串即可。
class Solution { public: string reverseWords(string s) { int left = 0, right = s.size() - 1; // 去掉字符串开头的空白字符 while (left <= right && s[left] == ' ') ++left; // 去掉字符串末尾的空白字符 while (left <= right && s[right] == ' ') --right; deque<string> d; string word; while (left <= right) { char c = s[left]; if (word.size() && c == ' ') { // 将单词 push 到队列的头部 d.push_front(move(word)); word = ""; } else if (c != ' ') { word += c; } ++left; } d.push_front(move(word)); string ans; while (!d.empty()) { ans += d.front(); d.pop_front(); if (!d.empty()) ans += ' '; } return ans; } };
选讲KMP
数组和字符串 - LeetBook - 力扣(LeetCode)全球极客挚爱的技术成长平台
https://leetcode.cn/leetbook/read/array-and-string/cm5e2/
三,双指针的两种情况:
1,方向相反
2,方向相同(快慢指针)
1,反转字符串
344. 反转字符串 - 力扣(LeetCode)
https://leetcode.cn/problems/reverse-string/
class Solution { public: void reverseString(vector<char>& s) { int n = s.size(); for (int left = 0, right = n - 1; left < right; ++left, --right) { swap(s[left], s[right]); } } };
2,移除元素
27. 移除元素 - 力扣(LeetCode)
https://leetcode.cn/problems/remove-element/
class Solution { public: int removeElement(vector<int>& nums, int val) { int n = nums.size(); int left = 0; for (int right = 0; right < n; right++) { if (nums[right] != val) { nums[left] = nums[right]; left++; } } return left; } };