反转字符串中的单词(Reverse Words)

来源:互联网 发布:网络电视怎么搜电视台 编辑:程序博客网 时间:2024/05/16 05:47
前言:在前面一篇文章中,我们在反转长度不等的两块连续内存块的算法的基础上推导出了反转长度不等的两块不连续内存块的算法。在本文中,我将使用前文分析的算法去解决一个更复杂的问题:反转字符串中的单词。

引子
话说有这样一道算法题:
PROBLEM:Reverse Words
COMMENT:Write a function that reverses the order of the words in a string.For instance,your function should transform the string "Do or do not,there is no try." to "try.no is there not,do or Do". Assume that all words are space delimited and treat punctuation the same as letters.

这个问题常见国内外各大IT论坛中,而且被多家著名的IT公司频繁拿来用来作为面试题去考察面试者的基本功。从题目涉及到的内容来看,这个题并不是很难,但是如果对时间复杂度和空间复杂度进行限制的话,在短时间内设计出一个让面试官满意的算法也并不是一件简单的事情。接下来,我首先简单介绍一下最常见的算法,这个算法思路比较直接,但是在时间复杂度和空间复杂度上很难满足条件。然后我就"隆重"介绍我所采用的算法,这个算法是前篇文章介绍的算法的一个延伸,并采用一个重要的解决问题的技巧-"Divide-and-Conquer"。

分析篇
当我们初次接触这个题的时候,我相信我们的脑海了已经浮现出一个完整的解决方案:
1。开辟一段内存用来存放反转后的结果,这段内存的大小应该不小于源字符串的大小。
2。由于我们要反转字符串,我们很自然的就会想到从字符串的尾部向字符串的头部遍历。
3。在遍历的过程中,当我们找到一个单词的时候,就把这个单词拷贝到目标内存块中。
4。当我们遍历了字符串中所有的字符时,遍历过程中。由于是从后向前遍历,这时并没有什么标识符标识结束,我们只有先记录下字符串的长度,当剩下的字符串长度为0时,遍历结束。
这个解决方案虽然简单,但是在实现的时候却总能碰到一些困难。由于我们是从后向前遍历,我们按顺序获得的单词中的字符是反序的,但是当我们把这个单词拷贝到目标内存块时却要正序拷贝。我们同时还要记录要拷贝单词的首地址和末地址,还要提防"Off-By-One"的错误。
除了实现起来会遇到点麻烦外,这个算法最大的问题就是在于目标内存块的开辟,导致在空间复杂度上不够理想。由于我们事先不可能知道源字符串的长度,我们更不能假定源字符串的最大长度(还记得"缓冲区溢出"么?),所以我们至少要开辟一段和源字符串一样长度的内存块,这样算法的空间复杂度就达到O(n)。同时这个算法首先要遍历字符串来获得字符串的长度,然后还要对字符串再进行一次遍历来获得字符串中的单词,这样算法的时间复杂度就达到O(n)。

是不是还存在优化的空间呢?答案当然时肯定的。如果我们跳出我们熟悉的思路框架,站的更高一点,从更高一级的抽象去分析这个问题,我们获得了一个完全不同于传统算法的解决方案。
从整体来说,我们要处理的是由多个单词(单词之间用空格分割)构成的字符串,这个字符串中基本的元素就是单词和空格。我首先拿几个简单的特例来分析一下,看看有没有什么收获。
1>  字符串中没有空格。这样字符串中的所有元素可以被看成是一个单词。这时候,就没有既要进行任何操作。
2>  字符串中有一个空格(此时暂时不考虑空格出现在字符串最前面或者最后面的情况)。这是反转两个单词的操作就等价于反转长度不等的两个不连续内存块的操作,这个操作我们已经在前篇文章中实现了。
3>  字符串中有两个空格。例如这样的字符串"Tom Jerry Mike",我们此时该如何处理这个字符串呢?结合前两个特例的分析,我立即想到了一种最常见的解决问题的思路-"Divide-and-Conquer"。总体来说,我们可以将包含多于一个空格的字符串划分成三个部分:
    |位于空格左边的子字符串|空格|位于空格右边的子字符串|

这样我们就要完成下面的处理:
step1。对位于空格左边的子字符串进行"Reverse Words"操作;
step2。对位于空格右边的子字符串进行"Reverse Words"操作;
step3。对整个字符串进行分转处理。
如果左右字符串中还包含多于一个空格,我们可以分别对他们再进行这样的分割+反转的操作,直到字符串中空格的数量不大于1。
看到这里,你是不是联想到一个常见的算法和这个算法有惊人的相似?对,就是"Merge Sort"算法,它们之间唯一的区别就在于"Merge Sort"中是进行排序操作,而这里需要进行反转操作。借用"Merge Sort"算法的人气,我把本文的算法称为"Merge Reverse"算法。
这里还剩下一个问题,对于字符串中的多个空格,我们该选用那个空格做分割点?如果你联想到了"Binary Search",答案就霍然明朗了,我们可以选用处于中间位置(median)的空格作为分割点。

实现篇
有了上面的分析,再结合前篇文章中实现的算法,本文的算法实现起来就显得十分简单了:
void* reverseStringByWord(void* pMemory,size_t memTotalSize)
{
    
if (NULL == pMemory)    return pMemory;
    
if (memTotalSize < 2)    return pMemory;

    unsigned 
char* pByteMemory = reinterpret_cast<unsigned char*>(pMemory);
    
    
int iTotalSeparator = 0;
    size_t
* pSeparatorIndexArray = reinterpret_cast<size_t*>(malloc(sizeof(size_t)*memTotalSize));
    
if (NULL == pSeparatorIndexArray)    return pMemory;
    
for (size_t i = 0; i < memTotalSize; i++{
        
if (*(pByteMemory+i) == SPACESEPARATOR) {
            
*(pSeparatorIndexArray+iTotalSeparator) = i;
            iTotalSeparator
++;
        }

    }

    
    
if (iTotalSeparator == 0{
        
//do nothing
    }
else if (iTotalSeparator == 1{
        size_t iMiddleSeparatorIndex 
= pSeparatorIndexArray[0];
        size_t iHeadBlockSize 
= iMiddleSeparatorIndex;
        size_t iEndBlockSize 
= memTotalSize-iHeadBlockSize-SEPARATORLENGH;
        swapNonadjacentMemory(pByteMemory,memTotalSize,iHeadBlockSize,iEndBlockSize);
    }
else {
        size_t iMiddleSeparatorIndex 
= pSeparatorIndexArray[iTotalSeparator/2];
        size_t iHeadBlockSize 
= iMiddleSeparatorIndex;
        size_t iEndBlockSize 
= memTotalSize-iHeadBlockSize-SEPARATORLENGH;

        reverseStringByWord(pByteMemory,iHeadBlockSize);
        reverseStringByWord(pByteMemory
+iHeadBlockSize+1,iEndBlockSize);
        swapNonadjacentMemory(pByteMemory,memTotalSize,iHeadBlockSize,iEndBlockSize);
    }


    free(pSeparatorIndexArray);
    
return pMemory;
}

测试篇
我写了一段测试用例来测试本文实现的算法:
void Test_reverseStringByWord() {
    
//table-driven test case
    static const char* testString[] = {
        
"",
        
"ab",
        
" a",
        
"",
        
"a b",
        
"ab cd ef",
        
"ab cd ef ",
        
" ab cd ed",
        
"aaa bbb ccc"
    }
;

    
void* pMemory = malloc(MAXMEMBUFFERSIZE);
    
if (NULL == pMemory)    return;
    
for (int i = 0; i < sizeof(testString)/sizeof(const char*); i++{
        printf(
"|%s|==>",testString[i]);

        size_t iStringLength 
= strlen(testString[i]);
        memset(pMemory,
0,MAXMEMBUFFERSIZE);
        memcpy(pMemory,testString[i],iStringLength);
        reverseStringByWord(pMemory,iStringLength);

        printf(
"|%s| ",reinterpret_cast<char*>(pMemory));
    }

    free(pMemory);
}

值得注意的是,这个算法在处理空格位于字符串的首部(或者尾部)的情况,还需要商榷和进一步的分析。

后记
通过本文的算法分析,我们能发现和总结一些算法设计的原则:
原则1:The Power of Primitive
一个复杂的问题往往可以被抽象成一个最基本的问题,这个基本的问题虽然简单,但是往往能够描述问题的实质。在我们解决问题的时候,能否正确的抓住问题的实质往往就是能够正确解决问题的关键。在本文介绍的算法中我们发现,在反转字符串中的单词的算法中最本质的操作就是交换不等长的两段不连续内存块。有了这个对问题本质的深入理解,我们就可以借助现有的算法轻松这个问题。

原则2:Divide-and-Conquer
这个思想不愧是解决很多问题的"不二法则"。看看算法书中有多少经典的算法应用了这个思路,我们就会惊叹的发现我们对它的认识还是很浅。这个思路最核心的本质,或者说成功的关键,就在于它可以将一个规模稍大的问题划分成一个或者多个规模稍小的问题,同时规模稍小的问题要比规模稍大的问题容易解决,只有这样,"Divide-and-Conquer" 才能现露它的神奇之处。在本文的算法中,反转字符串中的单词的问题最终被划分成反转两个不连续内存块的问题。

历史
12/16/2006   v1.0
原文的第一版
12/17/2006   v1.1
添加了后记部分,对本文中使用的两个算法设计原则进行了简单的总结

参考文献
1。《编程珠玑,第二版》
"The Power of Primitive"也是书中第二章的总结的一个算法分析原则。再一次强烈这本书。
2。《Foundations of Algorithms Using C++ Pseudocode, Third Edition
这本书中的第二章对"Divide-and-Conquer"进行深入分析,读后你一定会对它有了更深的认识和理解,这种深入的认识和理解对我们解决很多问题有很大帮助。
原创粉丝点击