zoukankan      html  css  js  c++  java
  • 面试常备题---数组总结篇(下)

         前面已经讲了数组题目中常用的几种方法:递归和循环,查找和排序,现在我们补充一下一些特例。

         基于数组的题目考查的知识点除了上面之外,还有其他一些细节,因为数组存放的是数据类型,而数据类型本身就有一些细节值得我们仔细推敲。

    题目一:输入一个正整数数组,把数组里所有数字拼接起来排成一个数,打印出最小的一个。

         我们还是从一个测试用例开始。

         假设一个数组{3, 32, 321},那么它所能排成的最小数字应该是321323。

         表面上这道题目是要求我们找出排列规则,但是它里面隐含了一个重大的问题:大数问题,把数组里的数字都拼起来的话,有可能造成溢出,这也是我们需要考虑的问题。

         解决大数问题最直观的的做法就是将数字转换成字符串,因为把数字m和n拼接起来得到的mn和nm位数都是一样的,所以我们可以按照字符串大小的比较顺序来:

    const int g_MaxValueLength = 10;
    
    char* g_StrCombine1 = new char[g_MaxValueLength * 10 + 1];
    char* g_StrCombine2 = new char[g_MaxValueLength * 10 + 1];
    
    void GetMinValue(int* data, int length)
    {
         if(data == Null || length <= 0)
        {
              return;
        }
    
        char** strValues = (char**)(new int[length]);
        for(int i = 0; i < length; ++i)
        {
              strValues[i] = new char[g_MaxValueLength + 1];
              sprintf(strValues[i], "%d", data[i]);
        }
    
        qsort(strValues, length, sizeof(char*), compare);
    
        for(int i = 0; i < length; ++i)
        {
              printf("%s", strValues[i]);
        }
        printf("
    ");
    
        for(int i = 0; i < length; ++i)
        {
              delete[] strValues[i];
        }
        delete[] strValues;
    }
    
    int compare(const void* value1, const void* value2)
    {
         strcpy(g_StrCombine1, *(const char**)value1);
         strcat(g_StrCombine1, *(const char**)value2);
         
         strcpy(g_StrCombine2, *(const char**)value2);
         strcat(g_StrCombine2, *(const char**)value2);
         
         return strcmp(g_StrCombine1, g_StrCombine2);
    }

         要想在短时间内做出这道题还真是不容易,因为它涉及到很多基本的函数。
         qsort这个函数顾名思义,就是快速排序函数,需要我们将定义比较规则的函数的函数指针传递给它就行。它就像我们java中的sort(),是的,这两者的原理是一样的。它的时间复杂度是O(N* log2N)。

         定义比较规则的函数则充分体现了泛型的使用意义,我们利用void*来模拟其他语言,像是java中的泛型,这是我们定义操作类型的函数经常使用的方法,当然,使用泛型需要我们进行强制类型转换。

         看看我们的比较规则:我们将两个数字按照两种排序方式拼接成两个字符串,接着就是比较这两个字符串的大小,因为字符串的大小比较默认是按照各个位上的字符大小来比较的,而数字字符的大小顺序和一般数字是一样的。

         就算是将整数转化为字符串来解决大数问题,但我们还是要想想这个字符串的大概位数。考虑到整数的范围,字符串的位数为10位就差不多了,因为要将两个10位的字符串拼接在一起,所以拼接后的字符串就需要20位了。

          这段代码非常严谨,因为我们的strValues数组中存放的元素占据的内存非常大,所以,在代码的最后我们需要释放掉这些内存,而且因为数组中的元素是字符串,也就是字符数组,所以我们需要先释放掉每个元素的内存,再释放点整个数组的内存,这个顺序不能颠倒,否则就会造成原本被释放掉的内存第二次被释放。

    题目二:一个整型数组里除了两个数字之外,其他的数字都出现了两次,求这两个数字,要求时间复杂度是O(N)和空间复杂度是O(1)。

          这道题非常难,如果我们不知道它的原理的话。

          我们从一个测试用例开始。

          假设一个数组{2, 4, 3, 6, 3, 2. 5, 5},那么结果应该输出的是2和4。

          但要得出这个答案,编码却变得非常复杂,因为时间复杂度和空间复杂度已经规定好了。空间复杂度要求是O(1),意味着我们只能用临时变量来保存结果,时间复杂度要求是O(N),说明我们的代码最多只有一次遍历。

          结合上面的讨论,我们发现很多思路都不行了,像是用一个数组来保存每个数字的出现次数就不行了,这时如果提示我们可以用异或运算,我们也许就会反应过来:任何一个数字异或它自己都等于0!

          要联想到这个基本的知识是非常难的,我们可以大胆点讲,尤其是面向对象编程,像是我这类的java人,对于这些异或运算之类的,平时根本想都不会去想,因为很少用到!

          就算提示我们可以使用异或,还是要好好想想怎么利用异或来找出这两个数。

          我们可以遍历整个数组,依次对每个数字进行异或,因为其中有两个数字只出现1次,像是上面的测试用例,最后的结果是0010,也就是说,我们得找出两个子数组,一个倒数第二位是1,另一个是0,然后在这两个子数组中将这两个数字找出来,方法依然是异或:

    void FindValue(int* data, int length, int* num1, int* num2)
    {
          if(data == NULL || length < 2)
          {
                 return;
          }
    
          int result = 0;
          for(int i = 0; i < length; ++i)
          {
              result ^= data[i];
          }
    
          unsigned int indexOf1 = FindFirstBitIs1(result);
          
          *num1 = *num2 = 0;
          for(int j = 0; j < length; ++j)
         {
              if(IsBit1(data[j], indexOf1))
              {
                    *num1 ^= data[j];
              }
              else
              {
                    *num2 ^= data[j];
               }
         }
    }
    
    unsigned int FindFirstBitIs1(int num)
    {
          int indexBit = 0;
          while((num & 1) == 0) && (indexBit < 8 * sizeof(int)))
          {
               num = num >> 1;
               ++indexBit;
          }
    
          return indexBit;
    }
    
    bool IsBit1(int num, unsigned int indexBit)
    {
          num = num >> indexBit;
          return (num & 1);
    }

         能够写出这样的代码的人一定是个高手,至少他不仅基础知识扎实,而且联想能力非常高。
         FindFirstBitIs1函数用来在整数num的二进制表示中找到最右边是1的位,而IsBit1的作用则是判断在num的二进制表示中从右边数起的indexBit位是不是1。

         如果是熟悉嵌入式开发的人,也许比较容易想到这个问题的解决方法,但像是我们这样很少和底层打交道的程序员,这道题基本上就已经将我们打死了!所以,我们还是要知道一些基本的位运算的作用,像是&,就经常用来取位数,像是2,我们可以表示为二进制10,将它各位与1进行与运算,就可以得到0和1。而且位运算有一个特点,像是这里的异或运算,假设result是num1和num2的运算结果,那么result ^ num1是可以得到num2的。

         如果在面试中遇到这类题目,就算不会做,也不用太垂头丧气,因为这种类型的题目真的是很难联想到。

    题目三:在数组中的两个数字如果前面一个数字大于后面的数字,则这两个数字组成一个逆序对。输入一个数组,求出逆序对的总数。

         数组中的排序并不一定是快速排序,像是这样的题目,使用的就是归并排序。

         我们先看看一个测试用例。

         假设数组{7, 5, 6, 4},那么一共存在5个逆序对:{7, 6}, {7, 5}, {7, 4}, {6, 4}和{5, 4}。

         我们可以先把数组分隔成子数组,先统计出子数组内部的逆序对的数目,然后再统计出两个相邻子数组之间的逆序对的数目。在统计逆序对的过程中,我们还需要对数组进行排序,而这种排序,就是归并排序。

          代码如下:

    int InversePairs(int* data, int length)
    {
         if(data == NULL || length <= 0)
        {
             return 0;
        }
    
        int* copy = new int[length];
        for(int i = 0; i < length; ++i)
        {
             copy[i] = data[i];
        }
    
        int count = InversePairsCore(data, copy, 0, length - 1);
        delete[] copy;
    
        return count;
    }
    
    int InversePairsCore(int* data, int* copy, int start, int end)
    {
        if(start == end)
        {
             copy[start] = data[start];
             return 0;
        }
    
        int length = (end - start) / 2;
        int left = InversePairsCore(copy, data, start, start + length);
        int right = InversePairsCore(copy, data, start + length + 1, end);
    
        int i = start + length;
        int j = end;
        int indexCopy = end;
        int count = 0;
        while(i >= start && j >= start + length + 1)
        {
            if(data[i] > data[j])
            {
                 copy[indexCopy--] = data[i--];
                 count += j - start - length;
            }
            else
            {
                  copy[indexCopy--] = data[j--];
            }
        }
    
        for(; i >= start; --i)
        {
             copy[indexCopy--] = data[i];
        }
    
        for(; j >= start + length + 1; --j)
        {
             copy[indexCopy--] = data[j];
        }
    
        return left + right + count;
    }

         归并排序的时间复杂度是O(N * log2N),但是它的空间复杂度是O(N)。
         比起快速排序,归并排序并不是一个容易写的排序算法,而且很多情况下的排序,都是直接采用快速排序,因为更加简单,更加快速,而归并排序是用在像是这样的特殊情况,在排序中还要进行操作,像是统计之类的。

          总而言之,如果我们需要将两个有序表合并为一个有序表,那么就是提示我们需要使用归并排序。

    题目三:设计一个算法,把一个含有N元素的数组循环右移K位,要求时间复杂度为O(N),并且只允许使用两个附加变量。

         这种题目咋看下很简单,只要将数组中的元素都右移一位,循环K次就行,但是这样的时间复杂度是O(N * K),并不符合要求,所以我们必须想想办法。

         我们的目标是要让这样的数组:abcd1234变成1234abcd,就是循环4次的结果。

         但是这样的想法是存在问题的,我们的K并不一定要比N小,K可以比N大,但仔细查看这样的结果,像是K为5的情况:d1234bac,而这种结果实质上与右移3位,也就是右移N - K位的结果是一样的,所以,循环的次数不应该是K,而是K % N,这样就能包含K大于N的情况。

         按照这样的思路,我们可以写出这样的代码:

    void RightShift(int* data, int N, int K)
    {
         K %= N;
         while(K--)
         {
               int t = data[N -  1];
               for(int i = N -1; i > 0; --i)
               {
                    data[i] = data[i - 1];
               }
               data[0] = t;
         }
    }

         这样我们可以将时间复杂度控制在O(N * N),但实际上还是不够好,我们可以进一步优化:

    void RightShift(int* data, int N, int K)
    {
         K %= N;
         Reverse(data, 0, N - K - 1);
         Reverse(data, N - K, N - 1);
         Reverse(data, 0, N - 1);
    }
    
    void Reverse(int* data, int b, int e)
    {
         for(; b < e; ++b, --e)
         {
              int temp = data[e];
              data[e] = data[b];
              data[b] = temp;
         }
    }

         这也是分治的策略,先移动前面的,再移动后面的,然后整体再移动。
    题目四:在一个二维数组中,每一行都按照从左到右递增的顺序排序,每一列都按照从上到下递增的顺序排序。输入一个这样的二维数组和一个整数,判断数组中是否含有该数。

          拿到这道题,首先的第一感觉就是遍历,但如果是这样的话,说明我们面试失败了!要写出一个遍历的答案不难,但优秀程序员所追求的的是简洁和效率,遍历一个数组就是为了找出该数组是否包含数字,时间效率是O(n),是非常低下的,因为这个n可以是非常大的数字。我们得想想办法。

          如果不想用遍历的解决方法,也就是寻找边界。考虑到二维数组就是一个矩阵,我们能够想到的特殊元素就是中心,四个边角。中心元素无法作为标记元素,因为无法保证这是一个正矩阵,我们只能从边角下手。题目的特点就是有序,可以利用这点,将遍历的范围缩小。我们应该选择的是右上角,因为它是一行中的最大元素,也是一列中最小的元素,像这种具有两方面特殊性的元素应该是我们程序员关注的重点,当然,左下角也是,因为它是一列中最大元素,一行中最小元素。
          如果我们的数字比该元素小,以该数字为首的那列就可以不用看了,我们接着将目标放在除开该列以外的元素上,这样递归下去,我们就能找到该元素。计算机非常喜欢递归,因为它们处理递归的速度非常快,基本上就是重复的操作,这也正是它们能比人更加优秀的地方:最快时间内处理重复的计算和动作。如果它比该元素大,我们就只要将目标放在该列上就行。
         当然,算法已经非常清楚,但是函数的设计并不仅仅是算法,还有消息接口。我们应该传怎样的消息进来呢?
         二维数组并不是每个程序员都喜欢的数据结构,事实上,最好是避免它,因为矩阵是个非常难以处理的东西。在C/C++中,我们可以这样传递一个一维数组:
      void HandleArr(int* array);

         传递数组名的意义就是让我们能够得到该数组所在的内存空间,因为数组是一块连续的内存空间,而数组名正是指向该内存空间的第一个元素的内存地址。但是二维数组不能这样做,因为二维数组本质上是数组的数组,它们的数组名指向的是第一个数组,我们无法知道其他数组的信息。所以,我们在传递一个二维数组的时候,都是在传递第一个数组的数组名的同时,传递它的行数和列数,这是因为我们一般都不直接传递一个数组,这是不好的行为。传递行数和列数是否是有必要的呢?我们可以通过这样的方式来获取数组的行数和列数:

    int rows = sizeof(array[0]) / sizeof(int);
    int columns = sizeof(array) / sizeof(rows);

         但该死的是,我们无法传递一个二维数组!所以我们只能传递它的行数和列数了,虽然这样的确是对用户很不友好,因为参数列表有点长,但我们的算法需要这些参数,所以也就能够容忍。

         代码如下:
    bool Find(int* data, int rows, int columns, int value)
    {
        bool found = false;
       
        if(data != NULL && rows > 0 && columns > 0)
        {
              int row = 0;
              int column = columns - 1;
              while(row < rows && column >= 0)
              {
                    if(data[row * columns + column] == value)
                    {
                          found = true;
                          break;
                    }
                    else if(data[row * columns + column] > value)
                    {
                          --column;
                    }
                    else
                    {
                          ++row;
                    }
              }
        }
         
        return found;
    }

         算法的设计其实并不难,但是如何将这个算法写的清楚则是个问题。像是这道题目,可能会有人这样写:

     while(array[row * columns + column] != number){}

        这样的条件写出来自己都觉得是个问题啊!首先,while的条件判断应该是边界条件判断,它结束的,应该是整个算法的结束,因为循环是非常浪费内存和时间的,所以一个算法应该只有一个循环,如果想要提高效率,可以考虑递归,但是递归的可读性并不直观。像是上面那种条件,应该是可能出现的三种情况之一,而并不是边界条件。

        这种问题就是因为我们当初学习编程的时候,只是大概知道循环是干嘛的,却从来都没有细想过,一个循环的出现到底意味着什么。
        二维数组的考察并不多见,因为光是一维数组就已经有很多东西可以考察了,如果遇到二维数组,也无需害怕,只要记得,它实质上就是一个数组的数组,然后运用数组的知识去解就行了,

       

          

  • 相关阅读:
    分支(选择)语句练习——7月22日
    语句:分支语句、switch case ——7月22日
    C#语言基础——7月21日
    进制转换——7月20日
    运行Tomcat报错 解决方法
    Mybatis面试题
    java面试题02
    当你没有能力去改变别人命运的时候 就不要随意去伸出援手......
    快速学习MD5的方法
    java面试题01
  • 原文地址:https://www.cnblogs.com/wenjiang/p/3308728.html
Copyright © 2011-2022 走看看