机械攻城狮C++学习日记Ⅰ:数组理论及相关常用算法

1.前言

        前情回顾: 计算机图形学中的符号距离场理论及应用      

        数组是一种数据格式,能够存储多个同类型的值。每个值都储存在一个独立的数组元素中,计算机在内存中依次存储数组的各个元素。本文对数组这种常见的数据结构类型进行简单介绍,系统讲解了与数组相关算法,并使用例题进行说明。

2.数组基础理论

        数组大家都很熟悉,这里只提醒几个容易忽略的点:

  1. 声明数组的通用格式为typeName  arrayName[arraySize],其中arraySize不能是变量,需要在编译时已知。
  2. 数组的索引下标从0开始,最后一个元素的索引比数组长度小1。
  3. 只有定义数组时才能初始化,也不能将一个数组赋给另一个数组。如果只对部分元素进行初始化,编译器将把其他元素设置为0。
  4. 数组是存放在连续内存空间上相同类型数据的集合,其内存空间地址是连续的。
  5. 数组的元素不能删除,只能覆盖,在删除过程中需要移动其他元素。

3. 二分法

题目:给定一个 n 个元素有序的(升序)整型数组 nums 和一个目标值 target  ,写一个函数搜索 nums 中的 target,如果目标值存在返回下标,否则返回 -1。

         二分法最早接触是在高中时期的求解非线性方程的根问题中,其定义为对于在区间[a,b]上连续不断且满足f(a)f(b)<0的函数y=f(x)通过不断地把函数f(x)的零点所在区间二等分,使区间两个端点逐步逼近零点,进而得到零点的近似值的方法。

        二分法的前提条件主要有两个:1、提供的数组必须有序2、数组中不含有重复元素,否则返回的下标可能不唯一。二分法的难点主要在于边界条件的确定,即对每一次循环中的区间定义,在二分查找的过程中,保持不变量,就是在while寻找中每一次边界的处理都要坚持根据区间的定义来操作,这就是循环不变量规则。本文定义 target 是在一个在左闭右闭的区间里,也就是[left, right] ,也就是说

  • while (left <= right) 要使用 <= ,因为left = right时满足区间定义条件。

  • if (nums[middle] > target) right 要赋值为 middle - 1,因为当前这个nums[middle]一定不是target,那么接下来要查找的左区间结束下标位置就是 middle - 1

class Solution {
public:
    int search(vector<int>& nums, int target) {
        int left = 0;
        int right = nums.size() - 1; // 定义target在左闭右闭的区间里,[left, right]
        while (left <= right) { // 当left==right,区间[left, right]依然有效,所以用 <=
            int middle = left + ((right - left) / 2);// 防止溢出 等同于(left + right)/2
            if (nums[middle] > target) {
                right = middle - 1; // target 在左区间,所以[left, middle - 1]
            } else if (nums[middle] < target) {
                left = middle + 1; // target 在右区间,所以[middle + 1, right]
            } else { // nums[middle] == target
                return middle; // 数组中找到目标值,直接返回下标
            }
        }
        // 未找到目标值
        return -1;
    }
};

        实际上,只要能够判断答案所在的区间,即使数据无序,也能够使用二分法。算法的时间复杂度为O(logn),空间复杂度为O(1)。看一则小故事休息一下进入下一道题。

有一天小明到图书馆借了 N 本书,出图书馆的时候,警报响了,于是保安把小明拦下,要检查一下哪本书没有登记出借。小明正准备把每一本书在报警器下过一下,以找出引发警报的书,但是保安露出不屑的眼神:你连二分查找都不会吗?于是保安把书分成两堆,让第一堆过一下报警器,报警器响;于是再把这堆书分成两堆…… 最终,检测了 logN 次之后,保安成功的找到了那本引起警报的书,露出了得意和嘲讽的笑容。于是小明背着剩下的书走了。 从此,图书馆丢了 N - 1 本书。 

4.双指针法

题目:给你一个数组 nums 和一个值 val,你需要原地移除所有数值等于 val 的元素,并返回移除后数组的新长度。

不要使用额外的数组空间,你必须仅使用 O(1) 额外空间并原地修改输入数组。

元素的顺序可以改变。你不需要考虑数组中超出新长度后面的元素

         此题由于不能使用额外空间,所以不可以创建新的数组将原数组中的需要的保留元素转移过去,由于数组的元素在内存地址中是连续的,不能单独删除数组中的某个元素,只能覆盖。 最简单的方法是找到val元素后将该元素与后面的元素逐一交换至数组结尾,并将记录数组元素的变量减一,代码如下:

class Solution {
public:
    int removeElement(vector<int>& nums, int val) {
        int len=nums.size();
        int i=0;
        int j=0;
        int t;
        for(i=0;i<len;i++)
        {
             if(nums[i]==val)
             {
                 j=i;
                 while(j<len-1)//注意数组越界
                 {
                     t=nums[j+1];
                     nums[j+1]=nums[j];
                     nums[j]=t;
                     j++;
                 }
                 i--;//调整下标
                 len--;
             }
        }
        return len;
    }
};

        双指针法,又叫快慢指针法,通过一个快指针和慢指针在一个for循环下完成两个for循环的工作。定义快慢指针

  • 快指针:寻找新数组的元素,新数组就是不含有目标元素的数组。

  • 慢指针:指向更新新数组下标的位置 

图1 快慢指针法示意图(来源:代码随想录)

 先放代码:

// 时间复杂度:O(n)
// 空间复杂度:O(1)
class Solution {
public:
    int removeElement(vector<int>& nums, int val) {
        int slowIndex = 0;
        for (int fastIndex = 0; fastIndex < nums.size(); fastIndex++) {
            if (val != nums[fastIndex]) {
                nums[slowIndex] = nums[fastIndex];
                slowIndex++;
            }
        }
        return slowIndex;
    }
};

        简单来说就是快指针用来遍历查找数组中的每个元素,如果查找到不需要删除的元素,就将快指针指向的元素覆盖至慢指针指向的元素,且快慢指针同时前进1。如果查找到需要删除的元素,则快指针前进1,慢指针不变,直到快指针遍历完整个数组。

5.滑动窗口法

题目:给定一个含有 n 个正整数的数组和一个正整数 s ,找出该数组中满足其和 ≥ s 的长度最小的 连续 子数组,并返回其长度。如果不存在符合条件的子数组,返回 0。

         本题难点在于需要找出连续的子数组,一种暴力的解法是直接两个for循环,不断寻找符合条件的子序列,并使用变量记录并更新子序列的长度,找到最短子数组,这样做的时间复杂度为O(n^{2}),但是leetcode更新了数据,暴力解法已经无法通过测试,就不放代码了,下面介绍滑动窗口法。

        所谓滑动窗口,就是不断的调节子序列的起始位置和终止位置,从而得出我们要想的结果。注意滑动窗口使用的三个要素:

  • 窗口内表示的含义,本题中就是满足条件的长度最小的连续子数组
  • 窗口起始位置的移动规则,本题中就是如果窗口内的元素和大于s,则元素需要减小,即起始位置向右移动。
  • 窗口结束位置的移动规则,本题中就是遍历数组的指针,也是for循环中的索引。

图2 滑动窗口示意图(来源:代码随想录)
// 时间复杂度:O(n)
// 空间复杂度:O(1)
class Solution {
public:
    int minSubArrayLen(int s, vector<int>& nums) {
        int result = INT32_MAX;
        int sum = 0; // 滑动窗口数值之和
        int i = 0; // 滑动窗口起始位置
        int subLength = 0; // 滑动窗口的长度
        for (int j = 0; j < nums.size(); j++) {
            sum += nums[j];
            // 注意这里使用while,每次更新 i(起始位置),并不断比较子序列是否符合条件
            while (sum >= s) {
                subLength = (j - i + 1); // 取子序列的长度
                result = result < subLength ? result : subLength;
                sum = sum + nums[i]; // 这里体现出滑动窗口的精髓之处,不断变更i(子序列的起始位置)
                i++;
            }
        }
        // 如果result没有被赋值的话,就返回0,说明没有符合条件的子序列
        return result == INT32_MAX ? 0 : result;
    }
};

        滑动窗口简单来说就是如果窗口内的元素和大于s,则起始位置右移,窗口终止位置不变。如果窗口内的元素和小于s,则窗口终止位置右移,起始位置不变,遍历过程中更新记录子序列长度的变量,并最终返回其最小值。该方法的精髓在于可以根据当前子序列和大小的情况,不断调节子序列的起始位置。从而将O(n^{2})暴力解法降为O(n)

6.模拟行为

题目:给定一个正整数 n,生成一个包含 1 到 n^2 所有元素,且元素按顺时针顺序螺旋排列的正方形矩阵。

 这类题不涉及过多算法,主要考察大家对自己代码的掌控程度,难点在于循环过程中边界条件的处理,顺时针填充矩阵的步骤为:

  • 填充上行从左到右
  • 填充右列从上到下
  • 填充下行从右到左
  • 填充左列从下到上

 要注意循环过程中的不变量,假设在填充每条边的过程中选择每条边左闭右开的原则,则有

图3 顺时针填充方式

这里每一种颜色,代表一条边,我们遍历的长度,可以看出每一个拐角处的处理规则,拐角处让给新的一条边来继续画。

class Solution {
public:
    vector<vector<int>> generateMatrix(int n) {
        vector<vector<int>> res(n, vector<int>(n, 0)); // 使用vector定义一个二维数组
        int startx = 0, starty = 0; // 定义每循环一个圈的起始位置
        int loop = n / 2; // 每个圈循环几次,例如n为奇数3,那么loop = 1 只是循环一圈,矩阵中间的值需要单独处理
        int mid = n / 2; // 矩阵中间的位置,例如:n为3, 中间的位置就是(1,1),n为5,中间位置为(2, 2)
        int count = 1; // 用来给矩阵中每一个空格赋值
        int offset = 1; // 需要控制每一条边遍历的长度,每次循环右边界收缩一位
        int i,j;
        while (loop --) {
            i = startx;
            j = starty;

            // 下面开始的四个for就是模拟转了一圈
            // 模拟填充上行从左到右(左闭右开)
            for (j = starty; j < n - offset; j++) {
                res[startx][j] = count++;
            }
            // 模拟填充右列从上到下(左闭右开)
            for (i = startx; i < n - offset; i++) {
                res[i][j] = count++;
            }
            // 模拟填充下行从右到左(左闭右开)
            for (; j > starty; j--) {
                res[i][j] = count++;
            }
            // 模拟填充左列从下到上(左闭右开)
            for (; i > startx; i--) {
                res[i][j] = count++;
            }

            // 第二圈开始的时候,起始位置要各自加1, 例如:第一圈起始位置是(0, 0),第二圈起始位置是(1, 1)
            startx++;
            starty++;

            // offset 控制每一圈里每一条边遍历的长度
            offset += 1;
        }

        // 如果n为奇数的话,需要单独给矩阵最中间的位置赋值
        if (n % 2) {
            res[mid][mid] = count;
        }
        return res;
    }
};

        除了上述传统方法外,还有一种根据坐标性质进行填充的代码供大家参考:

class Solution {
public:
    vector<vector<int>> generateMatrix(int n) {
        vector<vector<int>> res(n, vector<int>(n, 0));
        //方向向量,初始向右
        int dx = 0;
        int dy = 1;

        //坐标
        int x = 0;
        int y = 0;

        for (int i = 1; i <= n * n; i++) {
            res[x][y] = i;
            //这个判断语句最巧妙
            //如果撞头就向右调转90度 先加n再对n取模这个操作能防止越界,省了很多判断语句
            if (res[(x + dx + n) % n][(y + dy + n) % n] != 0) {
                int tem = dy;
                dy = -dx;
                dx = tem;
            }
            x += dx;
            y += dy;
        }
        return res;
    }
};

7.总结                                    

        数组就总结到这儿了,我主要是跟着代码随想录进行学习和总结的,拥抱开源= =鼓励大家一定要自己动手编程,我之前看的很多,动手的很少,到最后发现忘得也很快。最后发起了一个投票,欢迎大家评论区探讨机械出路(哭哭),后续会继续介绍线性表的数据结构与算法,觉得对您有一点帮助的话麻烦点个免费的赞吧!

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Topom

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值