0x00000002 1.数据结构和算法 基础数据结构 数组

文章目录

  • 基础知识简单总结
    • 特点
    • 优缺点
      • 优点
      • 缺点
      • 补充与思考*
  • C++11相关API(常用)
    • 裸数组 (不建议使用)
    • array(静态数组)
    • vector(动态数组)
  • 典型试题
    • 一元和多元数组的静态查找
      • 一元数组静态查找
        • 案例
      • 多元数组静态查找
        • 案例
    • 数组原地修改和遍历
      • 数组原地修改
        • 案例
      • 数组的遍历
        • 案例
    • 数据流场景下的问题
        • 案例
    • 滑动窗口(基础)
        • 案例
    • 子序列/子数组
    • 其他典型试题
      • 案例
  • References


基础知识简单总结

特点

  1. 内存中连续存储多个元素的结构(物理空间连续)
  2. 可以通过下标访问。(时间复杂度:O(1))
  3. 长度为n的数组下标范围[0,n-1]。
  4. 查找某一特定值,插入,删除操作时间较长。(时间复杂度O(n))

优缺点

优点

  1. 使用索引查找速度快。
  2. 使用索引遍历数组方便。

缺点

  1. 数组大小使用后无法改变。
  2. 添加,删除操作,查找某一特定值缓慢。
  3. 只能存储一种数据类型。

适合使用索引进行查询,删除和增加操作少的场景。

补充与思考*

  1. 数组模拟链表(静态链表)
  2. 动态数组(解决数组长度不定情况)

C++11相关API(常用)

下面举例都假定数组元素存储为int类型。

裸数组 (不建议使用)

  1. 声明和定义:
int arr[N] // N应为常量表达式的值或者常量,例如下面的示例
constexpr unsigned long int func(int n) {
	return (n <= 1)? n : func(n-1) + func(n-2); 
}// const 或const 的
int arr[func(11)];// 
  1. 访问和遍历
arr[0];

for (int i = 0; i < N; ++i) {
	...
}
for (auto i : arr) {
	...
}
  1. 多维数组,数组指针,指针数组声明和定义
int arr[N1][N2][N3]; // 多维数组,以三维为例
int *arr[N]; //指针数组,一个数组,内容为int类型指针
int (*arr)[N]; // 数组指针,一个指针,指向arr数组的首地址
  1. 访问
// 注意多维数组的范围for结合auto的用法
// 其他略
for (const auto& row: arr) { // 此处&不能省略。防止auto解释为指针
	for (const auto& index : row) { //此处可以省略
		cout << index;
	}
}

array(静态数组)

  1. 包含在array库文件中
#include 
using namespace std;
  1. 声明,定义以及初始化
    注:以下部分实例定义时候同时初始化
#include 
using namespace std;

array<int , 10> a; //定义一个arr,长度为10.
array<int, 3> a1{ {1, 2, 3} }
array<int, 3> a2{ 1, 2, 3} // 两种定义和初始化方式结果一样
array<int, 3> a3{ } // 初始化为0
array<int, 3> a4{1} // 第一个初始化为1,其他初始化为0
  1. 常用api(成员函数)
//接上文

//1. 迭代器
a.begin();a.end();//第一个元素,最后一个元素后的随机访问迭代器。
a.rbegin();a.rend();//最后一个元素,指向第一个元素前一个的随机访问迭代器。
a.cbegin();a.cend();//与第一行相同,只不过在其基础上增加了 const 属性,不能用于修改元素。
a.crbegin();a.crend();//与第二行相同,只不过在其基础上增加了 const 属性,不能用于修改元素。

//2. 容器元素数量相关
a.size(), a.max_size();//为初始化的空间大小
a.empty();// 判断数组是否为空

//3.元素操作
int n = 10, value = 11;
a.at(n),a[n];//返回n位置的引用,前者如果n不是有效范围,抛出out_of_range 异常;后者为无效操作(运行时返回一个未初始化的值,可能导致意想不到的错误)
a.front(),a.back(); //返回容器第一个元素/最后一个元素直接引用(注意空的array不适用)
a.data(); //返回容器首元素指针
a.fill(value); //将value赋值给a的每一元素
a1.swap(a2); // a1和a2元素互换(必须a1和a2长度一样)

vector(动态数组)

  1. 包含在vector库文件中
#include 
using namespace std;
  1. 声明,定义以及初始化
#include 
int value = 10;
array<int, 5> arr{0};

vector<int> v1;// 声明并且定义
vector<int> v2(arr.begin(),arr.begin() + n); // 通过迭代器或指针初始化
vector<int> v3(value) // 有value个0
vector<int> v4(value, 1) // 有value个1
vector<int> v5{ 1, 2, 3, 4, 5}; //声明定义后直接初始化
  1. 常用api(成员函数)
//接上文
//1. 迭代器,略,同array

//2. 容器元素数量相关
v1.size();// 同array,注意max_size()也有,表示能够容纳最大元素个数,两者不同
v1.empty(); //同array
v1.resize(value); //改变实际元素的个数
v1.reserve(value); //扩大容量到value大小
v1.capacity() //返回当前容量
v1.shrink_to_fit() // 将容量减少到等于当前元素实际所使用的大小

//3.元素访问和赋值
v1[0];v1.at();v1.front();v1.back();v1.data(); //同array
v1.assign(10, value)//分配常量值的语法,会覆盖之前的部分 10个value
v1.assign(v2.begin(),v2.begin() + 2) //从数组分配,此处可以换为指针
v1.assign({ 1, 2, 3, 3, 2, 1});//分配为一个列表

//4.增加和删除元素
v1.push_back(value);v1.emplace_back(value);// 在尾端加入容器,后者可以直接调用对应构造函数。
//push_back:容器尾端添加临时对象,然后调用构造函数,最后使用移动构造函数(注意不是拷贝构造)
//emplace_back:尾端直接添加一个元素,调用构造函数原地构造,不触发拷贝和移动构造。(如果传入已构造好的元素,那么与push_back一致)

v1.emplace(v1.end(), value); // 在指定的位置直接生成一个元素

//以下均返回第一个新插入元素位置的迭代器
v1.insert(v1.begin(),value); //插入结果:{value, v1[0],...}
v1.insert(v1.begin(),3,value); //在该位置插入3个value
v1.insert(v1.begin(),v1.begin() + 2,v1.begin() + 3); //在begin位置插入v1[2]
v1.insert(v1.begin(),{value}); // 该位置插入{}中元素

//删除元素后面一个迭代器
v1.erase(v1.begin()); //删除第一个元素
v1.erase(v1.begin(), v1.begin() + 2); // 删除第一个,第二个元素
 
v1.pop_back();//最后一个元素
v1.clear(); // 清楚所有元素

//5.其他
v1.swap(v2); //v1, v2互换

典型试题


考虑到数组出现频率高(如:试题数据给出形式,解答中构造辅助数组,答案返回值等)。并且是其他算法和技巧实现与结合的基础,比如排序算法,双指针,动态规划等。所以本部分试题总结仅考虑一下内容:

  • 一元和多元数组的静态查找(二分相关)
  • 数组原地修改(双指针相关)
  • 数据流场景下的问题
  • 滑动窗口(基础)
  • 子序列/子数组
  • 其他典型试题

一元和多元数组的静态查找

一元数组静态查找

针对数组静态查找,如果没有任何技巧,往往时间复杂度为O(n);
如果针对数据特点,能够制定某种测略,快速排除不符合要求(不必要的)答案,能够快速进行查找,从而使得复杂度有望降低找O(logn)水平:例如二分查找。下面是针对二分查找的补充说明:

  1. 二分查找最直接的应用是能够对有序数组进行查找。但注意不一定数组为有序,只需要满足使用某种测略,排除不符合要求(不必要的)答案即可;

  2. 有些时候,可以通过预处理等技巧构造符合1中条件的数组,从而降低复杂度;并且该技巧可以不构造数组,针对数据存在区间直接进行二分法操作。(由于博客主题,这里不例举题目)

  3. 下面是一些扩展性的优化讨论(P.S: 先放在这里,后面进行修改):二分法的核心是根据数据和场景状况,快速排除错误答案。我们根据这一点,将一些常用的限制条件放宽,进一步做出如下简单讨论:
    1). 由于有些场景受限,对于一些题目,最优解法不一定需要等分,而是需要根据题目条件灵活变化。如:扔鸡蛋问题。

    a. 这里先简单讨论经典情况:2个鸡蛋100层楼。由于可尝试次数与鸡蛋状况(碎与没碎)绑定的条件限制。将两个鸡蛋作用分为粗调和细调两种,接下来就是进一步讨论粗调和细调选取范围和方案。
    b. 其它情况讨论中可以发现动态规划能够优化这种策略:思路为k-1个鸡蛋用来逐步确定范围,最后一个鸡蛋。由于博客主题,本部分讨论省略。

    2). 二分法中的“二”基于的是一维数据”属于“和”不属于“对结果的可能性进行等可能划分下的优化解答。如果场景不止需要对两种情况进行优化,我们可以根据结果的可能性重新划分,快速排除可能性存在的空间。

    如:称量硬币。题意大致为给出一些硬币,硬币中有一枚可能是假币。如果是假币,可能轻或者重,要求尽可能少的使用不带砝码的天平找出那枚硬币(可能没有)。如果有,请说明它比其他硬币重还是轻。这里给出一个参考解答过程。

案例

  1. 搜索长度未知的有序数组
    思路:
    a. 需要找到left和right边界。不妨设left = 0,right = 1。根据right元素与target关系调整left和right边界。
    b. [left,right]上使用二分查找。
    核心代码

    /**
     * // This is the ArrayReader's API interface.
     * // You should not implement it, or speculate about its implementation
     * class ArrayReader {
     *   public:
     *     int get(int index);
     * };
     */
    class Solution {
    public:
        int search(const ArrayReader& reader, int target) {
            // 补丁:如果第一个值 left = 0 位置就是 target,防止后续查找丢失情况。
            int left = 0, right = 1;
            if (reader.get(left) == target) {
                return left;
            }
    
            // 确定左右边界
            while (reader.get(right) < target) {
                left = right; 
                right <<= 1; // right扩大一倍搜索
             }
    
             // 二分查找
            while (left < right) {
                int mid = left + ((right - left) >> 1);
                if (reader.get(mid) < target) {
                    left = mid + 1;
                } else if (reader.get(mid) >= target) {
                    right = mid;
                }
            }
            return reader.get(right) == target ? right: -1;
        }
    };		
    

    时间复杂度:O(logn)
    空间复杂度: O(1)

  2. 搜索旋转排序数组
    思路:
    a. 由于本题局部有序,且可以分为两段。我们尝试改进原版的二分查找求解。
    b. 将原数组分为两个part,我们改进判断条件。先使用left位置和mid看mid位于左边段还是右边段。(数字大于nums[left]段(左段)和数字小于nums[left]段(右段))
    c. 能够如此得原因是mid要么命中0->index段,要么命中index->right段。(index为原数组随机旋转下标)并且mid可以将这两段的每一段再分为两个小part。
    d. 在上面命中的那一块分为两个part内分别查找,迭代查找区间。
    核心代码

    class Solution {
    public:
        int search(vector<int>& nums, int target) {
            int left = 0, right = nums.size() - 1;
            while (left <= right) {
                int mid = left + ((right - left) >> 1);
    
                if (nums[mid] == target) {
                    return mid;
                // mid命中哪一个part
                } else if (nums[left] <= nums[mid]) {
                    // 分别查找每一段子part,没有排除一部分区间
                    if(target >= nums[left] && target < nums[mid]) {
                        right = mid - 1;
                    } else {
                        left = mid + 1;
                    }
                } else {
                    if (target > nums[mid] && target <= nums[right]) {
                        left = mid + 1;
                    } else {
                        right = mid - 1;
                    }
                }
            }
            return -1;
        }
    };	
    

    时间复杂度:O(logn)
    空间复杂度: O(1)

  3. 山脉数组的峰顶索引
    思路:
    a. 二分法,根据mid与mid + 1, mid - 1大小更新参数即可。
    b. 代码优化:只需要mid与mid+1比较大小。此时需要用ans记住峰值位置。
    核心代码

    	// 原始思路进行的代码
        int peakIndexInMountainArray(vector<int>& nums) {
            int left = 1, right = nums.size() - 2;
            while (left <= right) {
                int mid = left + ((right - left) >> 1);
                if (nums[mid] > nums[mid - 1] && nums[mid] > nums[mid + 1]) {
                    return mid;
                } else if (nums[mid] > nums[mid - 1] && nums[mid] < nums[mid + 1]) {
                    left = mid + 1;
                } else {
                    right = mid - 1;
                }
                return -1;
            }
        }
    
     	// 下面是根据最开始的思路进行一个小优化
    	class Solution {
    	public:
    	    int peakIndexInMountainArray(vector<int>& arr) {
    	        int left = 1, right = arr.size() - 2;
    	        int ans = 0;
    	        while (left <= right) {
    	            int mid = left + ((right - left) >> 1);
    	            if (arr[mid] > arr[mid + 1]) {
    	                right = mid - 1;
    	                ans = mid;
    	            } else {
    	                left = mid + 1;
    	            }
    	        }
    	        return ans;
    	    }
    	};	
    

    时间复杂度:O(logn)
    空间复杂度: O(1)

    扩展:局部最小值 :在一个无序数组(任何两个相邻数不等)中找出一个局部最小值。(视频1h42min处)

其他注意:

  1. 二分查找思路很容易,但是代码细节很多。代码模板可以参考下面博客:
    二分法其实很简单,为什么老是写不对!!
    二分查找模板总结
  2. 对于代码细节的补充案例:
    在排序数组中查找元素的第一个和最后一个位置
    搜索二维矩阵

多元数组静态查找

这里主要是二元数组,举出一个例子。

案例

  1. 二维数组中的查找(容易)
    注意与补充案例区别:搜索二维矩阵
    思路:
    第一步:将每一个方格左边一个当作是自己的left节点,下面一个当作是自己right节点(没有为空)。将矩阵右上角看作树根节点,逆时针旋转45度矩阵。原来的矩阵可以等效于一个BST树。
    第二步:问题转化为BST树中寻找一个值。
    核心代码
    class Solution {
    public:
        bool findNumberIn2DArray(vector<vector<int>>& matrix, int target) {
        	//排除异常情况
            if (matrix.size() == 0) {
                return false;
            }
            // 右上角开始搜索
            int pos_x = 0, pos_y = matrix[0].size() - 1;
            while (pos_x >= 0 && pos_x < matrix.size() && pos_y >= 0 && pos_y < matrix[0].size()) {
                if (matrix[pos_x][pos_y] > target) {
                    --pos_y;
                } else if (matrix[pos_x][pos_y] < target) {
                    ++pos_x;
                } else {
                    return true;
                }
            }
            return false;
        }
    };
    

    时间复杂度:O(m+n)
    空间复杂度:O(1)

数组原地修改和遍历

这里往往对code有一定要求:控制流程一定要对,不然很容易错,还且有一些边界情况。

数组原地修改

数组原地修改有很多经典题目,主要技巧是双指针(例如快排partition过程)。

  1. 双指针技巧有两种类型:a.用指针讲数组区间分为若干段,每一段有具体实际意义(从而导致每一个指针具有具体意义)。具体细节可以看看这个视频(讲得挺不错的)。b.指针本身具有一定意义,如:链表中环的入口节点。
  2. 扩展性的,不一定只使用两个指针,可以根据题意来设计(主要是清晰不同量代表含义和转移过程)。

对于多维数组,可以类似一维数组考虑,关键是调度转移过程。

案例

  1. 调整数组顺序使奇数位于偶数前面
    思路:
    a. 将数组分为3个part:奇数区间,未处理区间,偶数区间。
    b. 使用两个游标指向处理后的尾端(不含),处理后的偶数区间首端(不含)。
    c. 不停扩展两端区间,直到未处理区间没有。
    (回顾:基础快排partition处理流程:选出一个指标值index,将数组分为三个区间:小于index,未处理,大于index;流程为将未处理部分减少到0,完成一次处理。后面将两个part周而复始进行上述处理即可。)
    核心代码
    class Solution {
    public:
        vector<int> exchange(vector<int>& nums) {
            // left 指向处理过的最后一个奇数区间右边界(不含)
            // right 指向处理过的第一个偶数区间左边界(不含)
            int left = 0, right = nums.size() - 1; 
            while (left <= right) {
                if (nums[left] % 2 == 0) {
                    swap(nums[right--], nums[left]);
                } else {
                    ++left;
                }
                /* 这里不需要使得left 和 right 同时移动,即添加如下代码
                * 可能导致 left 或 right 越界 (例如:nums全部为奇数)
                * 如果要添加需要添加判断越界条件
                if (nums[right] % 2 == 1) {
                    swap(nums[left++], nums[right]);
                } else {
                    --right;
                }
                */
            }
            return nums;
        }
    };
    

    时间复杂度:O(n)
    空间复杂度:O(1)

  2. 旋转矩阵
    思路:
    a. 核心在于调度流程。这里需要举例一些特例。观察左上角的某一个元素旋转后实际只是对应元素交换,并且交换次数为4次。参考动画。
    b. 把矩阵分为4块,对其中一块进行上述调度即可。
    核心代码
    class Solution {
    public:
        void rotate(vector<vector<int>>& matrix) {
            int row = matrix.size();
            for (int i = 0; i < row / 2; ++i) {
                for (int j = 0; j < (row + 1)/ 2; ++j) { 
                // 注意奇数时候,调度col 为(n + 1)/ 2; 偶数依旧为 n/2(C++能够向下取整)
                    int temp = matrix[i][j];
                    matrix[i][j] = matrix[row - 1 - j][i];
                    matrix[row - 1 - j][i] = matrix[row - 1 - i][row - 1 - j];
                    matrix[row - 1 - i][row - 1 - j]  =  matrix[j][row - 1 - i];
                    matrix[j][row - 1 - i] = temp;
                }
            }
        }
    };	
    

    时间复杂度:O( n 2 n^{2} n2)
    空间复杂度:O(1)

数组的遍历

这里主要是多维数组的遍历,同样也是弄清楚调度转移过程,完成code即可。

案例

  1. Z字变换
    思路:根据题意,直接获得。(注意分组寻找规律)
    核心代码
    class Solution {
    public:
        string convert(string s, int numRows) {
            if (numRows == 1 || numRows >= s.size()) {
                return s;
            }
            string ans(s.size(), ' ');
            int pos = 0;
            for (int row = 0; row < numRows; ++row) {  // 每一行例举
                for (int k = 0; k * (numRows - 1) * 2 + row < s.size(); ++k)  { // 对每一个行的小块例举
                    ans[pos++] = s[k * (numRows - 1) * 2 + row];
                    if (row > 0 && row < numRows - 1 && (k + 1) * (numRows - 1) * 2 - row < s.size()) { // 第1~ numRows - 2 行需要每次额外添加新的元素,注意元素要有
                        ans[pos++] = s[(k + 1) * (numRows - 1) * 2 - row];
                    } 
                }
            }
            return ans;
        }
    };
    

    时间复杂度:O(n)
    空间复杂度:O(1)

  2. 螺旋矩阵
    思路: 设置边界线,注意控制循环边界即可。(类似剥洋葱一样,一层一层遍历)
    核心代码
    class Solution {
    public:
        vector<int> spiralOrder(vector<vector<int>>& matrix) {
            int rows = matrix.size(), cols = matrix[0].size();
            vector<int> arr(rows * cols, 0);
            int left = 0, right = cols - 1, bottom = rows - 1, top = 0; // 四个边界,类似剥洋葱式遍历。
            int pos = 0; // 添加值的下标
            while (pos < rows * cols) { // 剥去其中一层
                for (int col = left; col <= right; ++col) {
                    arr[pos++] = matrix[top][col];
                }
                for (int row = top + 1; row <= bottom; ++row) {
                    arr[pos++] = matrix[row][right];
                }
                if (left < right && top < bottom) { //另外一半的遍历是否要继续
                    for (int col = right - 1; col >= left; --col) {
                        arr[pos++] = matrix[bottom][col];
                    }
                    for (int row = bottom - 1; row > top; --row) {
                        arr[pos++] = matrix[row][left];
                    }
                }
                ++left;
                --right;
                ++top;
                --bottom;
            }
            return arr;
        }
    };
    

    时间复杂度:O(nm)
    空间复杂度:O(1)

数据流场景下的问题

数据流场景是现实中面临十分经典的场景。这个场景数据是以流的形式出现,并且随时需要快速返回需要信息。算法优化往往根据场景单独进行,属于比较难的问题。以下举出几个案例:

案例

  1. 数据流的中位数
    思路: 一个简单的构造思路。
    a. 使用一个大根堆和一个小根堆。大根堆记录中位数一下的数字,小根堆记录中位数以上的数字。
    b. 注意初始化和维护细节。
    (或者我们使用一个multiset维护整个数据,注意使用两个指针指向中间值)。
    核心代码
    class MedianFinder {
        priority_queue<int, vector<int>, less<int>> quemin; // 大根堆,存储后面的数字
        priority_queue<int, vector<int>, greater<int>> quemax; // 小根堆,存储前面的数字
    public:
        MedianFinder() {
    
        }
        
        void addNum(int num) {
            if (quemin.empty() || num <= quemin.top()) {
                quemin.push(num);
                if (quemin.size() > quemax.size() + 1) {
                    quemax.push(quemin.top());
                    quemin.pop();
                }
            } else {
                quemax.push(num);
                if (quemax.size() > quemin.size()) {
                    quemin.push(quemax.top());
                    quemax.pop();
                }
            }
        }
        
        double findMedian() {
            if (quemin.size() > quemax.size()) {
                return quemin.top();
            }
            return (quemax.top() + quemin.top()) / 2.0;
        }
    };
    
    /**
     * Your MedianFinder object will be instantiated and called as such:
     * MedianFinder* obj = new MedianFinder();
     * obj->addNum(num);
     * double param_2 = obj->findMedian();
     */
    

    时间复杂度:addNum: O(logn), findMedian:O(1)
    空间复杂度:O(n)

  2. 数据流中的第 K 大元素
    思路:
    使用小根堆,保留前k个元素即可。多余的pop()出来。
    核心代码
    class KthLargest {
        priority_queue<int, vector<int>, greater<int>> q;
        int k;
    public:
        KthLargest(int k, vector<int>& nums) {
            this->k = k;
            for (auto& x: nums) {
                add(x);
            }
        }
        
        int add(int val) {
            q.push(val);
            if (q.size() > k) {
                q.pop();
            }
            return q.top();
        }
    };
    
    /**
     * Your KthLargest object will be instantiated and called as such:
     * KthLargest* obj = new KthLargest(k, nums);
     * int param_1 = obj->add(val);
     */
    

    时间复杂度:add: O(logk), 初始化 O(nlogk)
    空间复杂度:O(k)

其他补充:

  1. 一些题目
  2. 蓄水池采样问题(参考本视频2:46秒讲解,具体一些问题在后续概率相关问题总结)

滑动窗口(基础)

滑动窗口技巧是指在一个特定窗口中处理某一任务,一般试题偏难。(具体技巧在字符串中总结)
这里贴出一个视频和帖子。总结了相关技巧和使用条件模板。下面直接给出案例:

案例

  1. 滑动窗口最大值
    思路: 最先想到是维持一个优先队列,如果元素不在窗口内直接pop。
    进一步优化:(单调队列/栈结构)由于我们需要求出的是滑动窗口的最大值。观察到以下事实:
    滑动窗口中有两个下标 i 和 j(且i < j,nums[i]≤nums[j])。窗口右移时候nums[i]必然不是最大值,可以删去。
    a. 考虑保持一个单调队列(没有移除的数据下标,其下标对应元素严格单调)
    b. 窗口右移, 与队尾元素比较。由于上述事实,当前元素若较大,删除队尾。否则直接压入。
    c. 当然,要注意清除失效下标(不在当前窗口内元素)。
    核心代码
    class Solution {
    public:
        vector<int> maxSlidingWindow(vector<int>& nums, int k) {
            deque<int> q; // 存下标原因是数组索引方便,并且后面还需排除不在窗口内下标。
            // 初始化单调队列
            for (int i = 0; i < k; ++i) {
                while(!q.empty() && nums[i] >= nums[q.back()]) { // 与队尾元素比较,队尾元素小的话直接抛弃
                    q.pop_back();
                }
                q.push_back(i);
            }
    
            vector<int> ans{nums[q.front()]} ;
            for (int i = k; i < nums.size(); ++i) {
                // 不停重复上操作
                while(!q.empty() && nums[i] >= nums[q.back()]) {
                    q.pop_back();
                }
                q.push_back(i);   
                // 添加一个操作:防止队列元素过时(相比窗口左端)
                while (q.front() <= i - k) {
                    q.pop_front();
                }
                ans.push_back(nums[q.front()]);         
            }
            return ans;
        }
    };
    

    时间复杂度:O(n)
    空间复杂度:O(k)

  2. 无重复字符的最长子串
    思路: 一个经典的滑动窗口问题。
    a. 使用hash表,维护窗口区间出现字符。
    b. 当有重复字符,从左端删除部分数据。否则移动右指针。
    c. 注意以上两步代码优化(顺序方面)。
    核心代码
    class Solution {
    public:
        int lengthOfLongestSubstring(string s) {
            unordered_set<char> st;
            int right = 0, ans = 0;
    
            for (int left = 0; left < s.size(); ++left) {
                // 如果非初次进入该处,表明统计区间有重复字符
                // 此时left 已经移动到下一个位置,注意删除起点为i - 1
                if(left != 0) {
                    st.erase(s[left - 1]);
                }
    
                //统计非重复字符
                while(right < s.size() && !st.count(s[right])) { 
                    // 不断移动右指针
                    st.insert(s[right]);
                    ++right;
                }
                ans = max(ans, right - left);
            }
            return ans;
        }
    };
    

    时间复杂度:O(n)
    空间复杂度:O(k) // k个字符在字符串中出现

子序列/子数组

这类问题十分经典。方法主要是贪心和动态规划。注意子序列可以不连续,子数组必须连续。这里举出几个例子:

  1. 最大子数组和
    思路:
    a. 观察到如果前面存在子数组和为负数时,后面如何累加都会比后面的子数组和小。
    b. 从而需要累加前面和,如果为负数;抛弃所有的局部和,重新累加。
    c. 注意全部为负数的情况。
    核心代码
    class Solution {
    public:
        int maxSubArray(vector<int>& nums) {
            int ans = nums[0]; // 防止全部为负数
            int sum = 0;
            for (int i = 0; i < nums.size(); ++i) {
                sum = max(nums[i], sum + nums[i]); // 如果遍历的局部和为负数,直接除去负数部分遍历和,保留当前元素。
                ans = max(ans, sum); //与前面所存留的局部和取最大。
            }
            return ans;
        }
    };
    

    时间复杂度:O(n)
    空间复杂度:O(1)

  2. 最长重复子数组
    思路: 枚举 nums1和 nums2所有的对齐方式:
    1)nums1不变,nums2的首元素与nums1中的某个元素对齐;
    2)nums2 不变,nums1的首元素与nums2中的某个元素对齐。
    我们计算它们相对位置相同的重复子数组即可。
    当然本题还有其他解法,参考link。
    核心代码
    class Solution {
    public:
        int findLength(vector<int>& nums1, vector<int>& nums2) {
            int len1 = nums1.size(), len2 = nums2.size();
            int ans = 0;
            // 移动nums1,进行一次对齐匹配
            for (int i = 0; i < len1; ++i) {
                int len = min(len2, len1 - i); // 计算对齐后共有长度
                ans = max(ans, maxLength(nums1, nums2, i, 0, len));
            }
            // 移动nums2,进行一次对齐匹配
            for (int i = 0; i < len2; ++i) {
                int len = min(len1, len2 - i); // 计算对齐后共有长度
                ans = max(ans, maxLength(nums1, nums2, 0, i, len));
            }
            return ans;
        }
    
        // off1, off2移动对齐后,一一匹配能够找到最长子数组长度
        int maxLength(vector<int>& nums1, vector<int>& nums2, int off1, int off2, int len) {
            int ans = 0 , part_len = 0; 
            for (int i = 0; i < len; ++i) {
                if (nums1[off1 + i] == nums2[off2 + i]) {
                    ++part_len;//统计所有相同的子数组长度
                } else { // 一个断掉直接置零
                    part_len = 0;
                }
                ans = max(ans, part_len);
            }
            return ans;
        }
    };
    

    时间复杂度:O(O((N+M)×min(N,M)))
    空间复杂度:O(1)

  3. 最长递增子序列
    思路: 一个经典的dp问题。(同样可以根据特点,使用贪心策略。这里省略,参考这个link)。
    动态规划思路大致如下:
    a. 考虑以i为尾端元素进行尝试。
    b. 设dp[i] 为考虑前 i 个元素,以第 i 个数字结尾的最长上升子序列的长度。
    转移方程为:
    d p [ i ] = m a x ( d p [ j ] ) + 1 , 0 ≤ j < i 且 n u m [ j ] < n u m [ i ] dp[i]=max(dp[j])+1, 0≤jdp[i]=max(dp[j])+1,0j<inum[j]<num[i]

    c. 然后获得dp数组最大值即可。
    d. 上述思路基于一个事实,就是i一定小于等于前 个元素以第个数字结尾的最长上升子序列的长度。
    核心代码
    class Solution {
    public:
        int lengthOfLIS(vector<int>& nums) {
            vector<int> dp(nums.size(), 0);
            for (int i = 0; i < nums.size(); ++i) {
                dp[i] = 1; // 至少最长序列为1,即只有自己
    
                //向前遍历,更新当前dp
                for (int j = 0; j < i; ++j) {
                    if (nums[j] < nums[i]) { // 满足增加1的条件
                        dp[i] = max(dp[i], dp[j] + 1); // 按照转移方程更新
                    }
                }
            }
            return *max_element(dp.begin(), dp.end());
        }
    };
    

    时间复杂度:O( n 2 n^{2} n2)
    空间复杂度:O(n)

其他补充:
这类题目还有很多,并且解题方法类型也多种多样。比如:
递增子序列
和为 K 的子数组
最长重复子串

其他典型试题

这些试题场景比较特殊,一般用特别的算法优化这些问题(例如贪心)。下面直接举出一些案例:

案例

  1. 摩尔投票法:求众数 II
    思路: 本题首先要知道该题的解法。这里需要将原来1/2的进行推广,可以参考这个帖子。
    当然,本题一个比较简单的求法是使用哈希表。这里就不详细讲解思路了。
    核心代码
    class Solution {
    public:
        vector<int> majorityElement(vector<int>& nums) {
            vector<int> ans;
            int elem1 = 0, elem2 = 0; // 候选元素
            int vote1 = 0, vote2 = 0; // 血量(遇到与候选匹配加1,否则减去)
    
            for (int x : nums) {
                // 元素计数
                if (vote1 > 0 && x == elem1) {
                    ++vote1;
                } else if (vote2 > 0 && x == elem2) {
                    ++vote2;
                // 第一次选择元素或候选元素统计时被抵消了
                } else if (vote1 == 0) {
                    elem1 = x;
                    ++vote1;
                } else if (vote2 == 0) {
                    elem2 = x;
                    ++vote2;
                } else {
                    --vote1;
                    --vote2;
                }
            }
            // 得到候选解后需要统计是否两个都是满足题意的(统计)
    
            int cnt1 = 0, cnt2 = 0;
            for (auto x : nums) {
                if (vote1 > 0 && x == elem1) {
                    ++cnt1;
                }
                if (vote2 > 0 && x == elem2) {
                    ++cnt2;
                }
            }
    
            if (vote1 > 0 && cnt1 > nums.size() / 3) {
                ans.push_back(elem1);
            }
            if (vote2 > 0 && cnt2 > nums.size() / 3) {
                ans.push_back(elem2);
            }
    
            return ans;
        }
    };
    

    时间复杂度:O(n)
    空间复杂度:O(1)

  2. 跳跃游戏
    思路: 本题是一个经典的贪心优化。优化思路可以参考此处link。
    核心代码
    class Solution {
    public:
        int jump(vector<int>& nums) {
            int maxpos = 0; // 目前能够到达的最远位置
            int step = 0; // 跳跃次数
            int end = 0; // 上次跳跃可以达到的右边界
            for (int i = 0; i < nums.size() - 1; ++i) {
                maxpos = max(maxpos, i + nums[i]); // 更新目前能够跳跃的最远位置
                if (i == end) { // 如果当前已经跳跃到最远位置
                    end = maxpos; // 更新信息
                    ++step; //一定能够从上一次的位置跳一步到达end
                }
            }
            return step;
        }
    };	
    

    时间复杂度:O(n)
    空间复杂度:O(1)

  3. 盛最多水的容器
    思路: 十分经典的双指针问题。
    过程十分容易,每一移动高度较小的指针即可(因为移动另一个一定会变小)。
    证明过程见:link。
    核心代码
    class Solution {
    public:
        int maxArea(vector<int>& height) {
            int left = 0, right = height.size() - 1;
            int ans = 0;
            while (left < right) {
                int area = min(height[left], height[right]) * (right - left);
                ans = max(area , ans);
                if (height[left] <= height[right]) { // 哪边较小往,移动哪边指针。
                    ++left;
                } else {
                    --right;
                }
            }
            return ans;
        }
    };
    

    时间复杂度:O(n)
    空间复杂度:O(1)

  4. 最大数
    思路:
    a. 一道经典的贪心问题。策略时排序,排序方式是x,y两个数组合:x(y), y(x);哪一个数放前面组合较大哪一个数排在前面。
    b. 这样导致整体序列组合后最大。证明见link。
    (思路,先证明这种排序构造,形成的不等式具有传递性,然后证明这种方式排序后组合得到的数最大。)
    核心代码
    class Solution {
    public:
        string largestNumber(vector<int>& nums) {
            // 排序比较函数如下:x(y) > y(x)
            auto f = [] (const int x,const int y) {
                long sx = 10, sy = 10;
                while (sx <= x) {
                    sx *= 10;
                }
                while (sy <= y) {
                    sy *= 10;
                }
                return sy * x + y > sx * y + x;
            };
            sort(nums.begin(), nums.end(), f);
            // 防止先导0出现(此时数组全为0)
            if (nums[0] == 0) {
                return "0";
            }
            string ans;
            for (int x : nums) {
                ans += to_string(x);
            }
            return ans;
        }
    };
    

    时间复杂度:O(nlogn*log(INT_MAX))
    空间复杂度:O(logn)

References

  1. 基础知识部分参考:https://www.bilibili.com/video/BV13f4y1k7kw
  2. array部分: https://en.cppreference.com/w/cpp/container/array
  3. vector部分:https://en.cppreference.com/w/cpp/container/vector
  4. 题目来源:https://leetcode-cn.com/
欢迎评论指正和补充

你可能感兴趣的:(基础数据结构,数据结构和算法,数据结构,算法)