Fork me on GitHub
数组总结篇(下)

数组总结篇(下)

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

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

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

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

     假设一个数组{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("\n");

    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的条件判断应该是边界条件判断,它结束的,应该是整个算法的结束,因为循环是非常浪费内存和时间的,所以一个算法应该只有一个循环,如果想要提高效率,可以考虑递归,但是递归的可读性并不直观。像是上面那种条件,应该是可能出现的三种情况之一,而并不是边界条件。

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

 

 

 

 

 

 

 

   

      

 
 
 
标签: 面试
posted on 2013-09-09 14:31  HackerVirus  阅读(206)  评论(0编辑  收藏  举报